feat(theseus): consume owner-signed DNS records alongside on-chain records

Owners can now publish a signed _records.json (A/AAAA/MX/TXT/CNAME/NS)
beside their Sia content; the gateway verifies it against the current NFT
holder and serves it as GET /api/dns/<name>. Every BCDN resolution now
starts a background fetch of that answer (3 s cap, 30 s cache, seq rollback
guard) and attaches it to the entry as entry.dns. Navigation never waits
for it — on-chain h/s3/ip/p/u stay authoritative — except when a name has
no content record at all and a signed A is the only way to reach it. Only
registered names are looked up, so ICANN hosts never reach the gateway.

Exposed as window.bcnr.dnsRecords(name) for add-ons (TXT verification, MX
for mail bridges), on resolveName() as .dns, and as a "Signed DNS" row in
the site-info popover.
This commit is contained in:
Local Dev 2026-09-16 00:53:13 +02:00
parent 621758834a
commit 27a1243e4d
3 changed files with 105 additions and 2 deletions

View file

@ -20,6 +20,11 @@ contextBridge.exposeInMainWorld("bcnr", {
isRegistered: (name) => ipcRenderer.invoke("bcnr:isRegistered", name), isRegistered: (name) => ipcRenderer.invoke("bcnr:isRegistered", name),
getBcnrTlds: () => ipcRenderer.invoke("bcnr:getBcnrTlds"), getBcnrTlds: () => ipcRenderer.invoke("bcnr:getBcnrTlds"),
getRecordVersion: (name) => ipcRenderer.invoke("bcnr:getRecordVersion", name), getRecordVersion: (name) => ipcRenderer.invoke("bcnr:getRecordVersion", name),
// Owner-signed DNS records (A/AAAA/MX/TXT/CNAME/NS) published beside the
// name's Sia content and verified by the gateway against the current NFT
// holder. `{ name, dns, seq, updatedAt, owner }`, or null when the name is
// unregistered or has published no manifest. Waits ≤ 3 s for a fetch.
dnsRecords: (name) => ipcRenderer.invoke("bcnr:dnsRecords", name),
// Diagnostic — the eTLD+1 permission origin Theseus computes for THIS page. // Diagnostic — the eTLD+1 permission origin Theseus computes for THIS page.
// dApp devs use this to see how their subdomains bucket under one grant. // dApp devs use this to see how their subdomains bucket under one grant.
// Returns null for opaque origins (data:, blob:) which never hold grants. // Returns null for opaque origins (data:, blob:) which never hold grants.

101
main.js
View file

@ -150,6 +150,77 @@ const SEARCH = (q) => engineUrl(settings.searchEngine, q);
// Public content relay (secret-free): serves s3/ip/h/u without shipping keys. // Public content relay (secret-free): serves s3/ip/h/u without shipping keys.
const GATEWAY = "https://navigate.st"; const GATEWAY = "https://navigate.st";
// ---- Signed DNS records ----------------------------------------------------
// Owners can publish an owner-signed `_records.json` manifest on Sia with
// classic DNS data (A/AAAA/MX/TXT/CNAME/NS). The gateway verifies the
// signature against the current NFT holder and serves the verified `dns`
// block as GET /api/dns/<name> (Decentralized.DNS/INTEGRATION-signed-records-
// clients.md). These records EXTEND on-chain records and never override them:
// h/s3/ip/p/u stay authoritative for content. We fetch them in the background
// on every BCDN resolution with a 3 s cap and hang the result on the entry as
// `entry.dns`; a navigation never waits for the fetch, except when a name has
// no on-chain content record at all and a signed A record is the only way to
// reach it. Only registered names are looked up, so ICANN hosts the user
// visits are never sent to the gateway.
const DNS_RECORDS_TTL = 30_000; // matches the gateway's Cache-Control max-age=30
const dnsRecordsCache = new Map(); // name -> { value, at, seq, pending }
function dnsRecordsCached(name) {
const c = dnsRecordsCache.get(name);
return c && Date.now() - c.at < DNS_RECORDS_TTL ? c.value : undefined;
}
function fetchDnsRecords(name) {
const c = dnsRecordsCache.get(name);
if (c?.pending) return c.pending;
if (c && Date.now() - c.at < DNS_RECORDS_TTL) return Promise.resolve(c.value);
const prev = c?.value ?? null;
const pending = (async () => {
let value = prev;
try {
const r = await fetch(`${GATEWAY}/api/dns/${encodeURIComponent(name)}`, { signal: AbortSignal.timeout(3000), cache: "no-store" });
if (r.ok) {
const j = await r.json();
const seq = Number(j?.seq) || 0;
// Rollback guard: a manifest with a lower seq than one already seen
// for this name is stale (or replayed) — keep what we had.
if (j && j.dns && typeof j.dns === "object" && seq >= (c?.seq ?? -1)) {
value = { dns: j.dns, seq, updatedAt: j.updated_at || null, owner: j.verified_owner || null };
}
} else if (r.status === 404 || r.status === 409) {
value = null; // no manifest declared / nowhere to keep one
}
} catch { /* offline, timeout, bad JSON — records are optional */ }
dnsRecordsCache.set(name, { value, at: Date.now(), seq: Math.max(c?.seq ?? -1, value?.seq ?? -1), pending: null });
return value;
})();
dnsRecordsCache.set(name, { value: prev, at: c?.at ?? 0, seq: c?.seq ?? -1, pending });
return pending;
}
// Kick off the fetch for a resolved entry and attach the answer when it lands.
// `entry.dns` is undefined while unknown, null when the owner published no
// manifest, or { dns, seq, updatedAt, owner }.
function attachDnsRecords(entry) {
if (!entry || !entry.name) return;
const cached = dnsRecordsCached(entry.name);
if (cached !== undefined) { entry.dns = cached; return; }
fetchDnsRecords(entry.name).then((v) => { entry.dns = v; }, () => {});
}
// Record types the manifest actually carries (for the site-info popover).
function dnsRecordKinds(entry) {
const d = entry?.dns?.dns;
if (!d || typeof d !== "object") return [];
return Object.keys(d).filter((k) => Array.isArray(d[k]) ? d[k].length > 0 : d[k] != null && d[k] !== "");
}
// First signed IPv4 address for a name — the reachability fallback when the
// chain carries no content record. Waits for an in-flight fetch (≤ 3 s) only
// because there is nothing else to serve.
async function dnsAddressFor(entry) {
if (!entry?.name) return null;
const v = entry.dns !== undefined ? entry.dns : await fetchDnsRecords(entry.name);
const a = v?.dns?.A;
const ip = Array.isArray(a) ? a.find((x) => typeof x === "string" && /^\d{1,3}(\.\d{1,3}){3}$/.test(x)) : null;
return ip || null;
}
// ---- BNS name detection (multi-TLD) -------------------------------------- // ---- BNS name detection (multi-TLD) --------------------------------------
// Theseus is a BNS-native browser: BCNR is the priority registry for EVERY // Theseus is a BNS-native browser: BCNR is the priority registry for EVERY
// dotted host, regardless of TLD. The engine (resolver-web.js) resolves any // dotted host, regardless of TLD. The engine (resolver-web.js) resolves any
@ -1362,6 +1433,9 @@ async function resolveHost(host) {
} }
} }
if (entry) entries.set(host.toLowerCase(), { entry, host: host.toLowerCase() }); if (entry) entries.set(host.toLowerCase(), { entry, host: host.toLowerCase() });
// Signed DNS records ride alongside the on-chain answer — started here,
// never awaited (see attachDnsRecords).
if (entry) attachDnsRecords(entry);
maybeRefreshElectrum(); maybeRefreshElectrum();
return entry; return entry;
} }
@ -1433,6 +1507,14 @@ async function serveBns(request) {
if (r.ip) return await serveIp(); if (r.ip) return await serveIp();
if (!isSubdomain && r.p) return await serveP(); if (!isSubdomain && r.p) return await serveP();
if (r.u) return Response.redirect(r.u, 302); if (r.u) return Response.redirect(r.u, 302);
// No on-chain content record. If the owner published a signed DNS A
// record, that server is the only way to reach the name — same Host-header
// semantics as an `ip` record (and the on-chain `tls` pin still applies).
const dnsIp = await dnsAddressFor(rec.entry);
if (dnsIp) {
const up = await ipRequest(dnsIp, reqPath + url.search, host, r.tls);
return new Response(up.buffer, { status: up.status, headers: { "content-type": up.contentType || guessType(reqPath) } });
}
return new Response(JSON.stringify(rec.entry, null, 2), { headers: { "content-type": "application/json" } }); return new Response(JSON.stringify(rec.entry, null, 2), { headers: { "content-type": "application/json" } });
} catch (e) { } catch (e) {
// The upstream fetch failed — relay unreachable, DNS stalling, site's // The upstream fetch failed — relay unreachable, DNS stalling, site's
@ -3140,7 +3222,7 @@ async function loadBns(t, id, host, rest, tld) {
: (!_isSub && entry.records.p) ? "mirror" : (!_isSub && entry.records.p) ? "mirror"
: entry.records.u ? "redirect" : entry.records.u ? "redirect"
: "record"; : "record";
t.prov = { host, kind: "ok", source: src, category: entry.category, records: Object.keys(entry.records), tld, registry }; t.prov = { host, kind: "ok", source: src, category: entry.category, records: Object.keys(entry.records), dns: dnsRecordKinds(entry), tld, registry };
if (id === activeId) pushNav(t.prov); if (id === activeId) pushNav(t.prov);
emitTabs(); emitTabs();
} }
@ -3905,7 +3987,7 @@ async function switchRegistry(arg) {
: (!_isSub && r.p) ? "mirror" : (!_isSub && r.p) ? "mirror"
: r.u ? "redirect" : r.u ? "redirect"
: "record"; : "record";
t.prov = { host, kind: "ok", source: src, category: rec?.entry?.category, records: Object.keys(r), tld, registry }; t.prov = { host, kind: "ok", source: src, category: rec?.entry?.category, records: Object.keys(r), dns: dnsRecordKinds(rec?.entry), tld, registry };
} }
} catch (e) { console.warn("collision-switch load failed:", e?.message); } } catch (e) { console.warn("collision-switch load failed:", e?.message); }
finally { t.internalNav = false; setLoading(t, false); } finally { t.internalNav = false; setLoading(t, false); }
@ -5119,6 +5201,9 @@ function serializeEntry(entry) {
txid: entry.txid, txid: entry.txid,
height: entry.height, height: entry.height,
updatedTxid: entry.updatedTxid ?? null, updatedTxid: entry.updatedTxid ?? null,
// Signed DNS records as last fetched: undefined → not known yet (call
// bcnr:dnsRecords to wait for them), null → none published.
dns: entry.dns === undefined ? undefined : entry.dns,
}; };
} }
ipcMain.handle("bcnr:resolveName", async (_e, name) => { ipcMain.handle("bcnr:resolveName", async (_e, name) => {
@ -5126,6 +5211,18 @@ ipcMain.handle("bcnr:resolveName", async (_e, name) => {
try { return serializeEntry(await resolveHost(name)); } try { return serializeEntry(await resolveHost(name)); }
catch { return null; } catch { return null; }
}); });
// Signed DNS records for a registered name, waiting (≤ 3 s) for the fetch.
// For add-ons that need TXT (verification, cert pins), MX (mail bridges) or
// A/AAAA. Null when the name is unregistered or has no manifest.
ipcMain.handle("bcnr:dnsRecords", async (_e, name) => {
if (typeof name !== "string" || !name) return null;
try {
const entry = await resolveHost(name);
if (!entry) return null;
const v = entry.dns !== undefined ? entry.dns : await fetchDnsRecords(entry.name);
return v ? { name: entry.name, ...v } : null;
} catch { return null; }
});
ipcMain.handle("bcnr:isRegistered", async (_e, name) => { ipcMain.handle("bcnr:isRegistered", async (_e, name) => {
if (typeof name !== "string" || !name) return false; if (typeof name !== "string" || !name) return false;
try { return (await resolveHost(name)) != null; } try { return (await resolveHost(name)) != null; }

View file

@ -103,6 +103,7 @@
details = row("Served from", esc(d.source) || "chain") details = row("Served from", esc(d.source) || "chain")
+ row("Registry", "BCDN — Bitcoin Cash Domain Names") + row("Registry", "BCDN — Bitcoin Cash Domain Names")
+ (d.records && d.records.length ? row("Records", `<code>${esc(d.records.join(", "))}</code>`) : "") + (d.records && d.records.length ? row("Records", `<code>${esc(d.records.join(", "))}</code>`) : "")
+ (d.dns && d.dns.length ? row("Signed DNS", `<code>${esc(d.dns.join(", "))}</code>`) : "")
+ (d.category ? row("Certificate", `<code>${esc(String(d.category).slice(0, 20))}…</code>`) : ""); + (d.category ? row("Certificate", `<code>${esc(String(d.category).slice(0, 20))}…</code>`) : "");
} }
hero.className = cls; $("ico").innerHTML = ico; $("status").textContent = status; $("sub").innerHTML = sub; hero.className = cls; $("ico").innerHTML = ico; $("status").textContent = status; $("sub").innerHTML = sub;