Theseus 0.3.98: Namecoin + Handshake adapters

Two more adapters under Settings › Names & Certificates:

Namecoin — .bit names resolve by calling name_show "d/<name>" on the
configured JSON-RPC endpoint and following `translate` or `alias` records
to a clearnet host. Pure IP records aren't followed yet (that needs
in-browser DNS override + TLS pinning, which comes later). Namecoin has
no free public RPC, so the toggle is off by default and the row carries a
"Needs an RPC URL" note instead of a "Not wired yet" one; set yours in
Advanced.

Handshake — non-ICANN hostnames resolve through the public hns.to web
gateway (user-overridable in Advanced). hns.to proxies the request to a
Handshake node and serves the on-chain content. A bundled list of common
ICANN TLDs (original gTLDs + popular new gTLDs + every ccTLD) keeps the
default collision policy ("icann-wins") from hijacking clearnet names.
Setting the policy to "hns-wins" removes that gate.

Both adapters follow the ENS adapter's shape — tryExtraResolvers in main.js
calls each in turn after the BNS lookup; the first one that returns a URL
loads it. Settings copy on both rows updated.
This commit is contained in:
Local Dev 2026-10-21 02:40:33 +03:00
parent 6001686f09
commit 227d013013
5 changed files with 198 additions and 17 deletions

View file

@ -0,0 +1,58 @@
"use strict";
// Handshake resolver — routes non-ICANN TLDs through a public HNS web
// gateway (hns.to by default). hns.to receives `foo.<tld>.hns.to`, looks up
// the Handshake TLD, fetches the on-chain site, and serves it back. From
// Theseus's side the adapter is a one-liner URL rewrite + a collision check
// to decide whether to touch the hostname at all.
//
// The collision check matters: Handshake auctioned on-chain TLDs that
// collide with real DNS names (e.g. ".app", ".dev"). We don't want to
// hijack those by default. With collision_policy = "icann-wins" (default),
// the adapter returns null for any TLD in a bundled "common ICANN" list so
// clearnet takes over. With "hns-wins", we always try HNS first.
//
// The list isn't the full IANA root zone (~1600 entries). It's the few
// hundred TLDs real users actually see: original gTLDs, popular new gTLDs,
// every ccTLD. If a TLD escapes the list and happens to collide with an
// HNS registration, the user can switch to "icann-wins" and whitelist via
// per-host overrides (future). Nothing breaks silently — the collision is
// opt-in by the user turning HNS on.
const CACHE_TTL_MS = 10 * 60 * 1000;
const CACHE = new Map();
// Common ICANN TLDs — the ones a browser would actually see in practice.
// Not exhaustive; grown as needed.
const ICANN_TLDS = new Set([
// original gTLDs
"com","org","net","edu","gov","mil","int","arpa","info","biz","name","pro","museum","aero","coop","jobs","mobi","tel","travel","cat","asia","post","tv","me","cc","ws","bz","su","ac",
// common new gTLDs
"app","dev","io","co","xyz","online","site","shop","store","tech","cloud","blog","news","world","world","global","global","email","agency","media","design","photo","art","gallery","pub","bar","cafe","wine","rest","restaurant","hotel","travel","fitness","gym","run","bike","boats","car","cars","fish","flight","flights","garden","farm","pet","pizza","realtor","salon","school","soccer","software","solar","taxi","vet","yoga","academy","band","bank","beer","beauty","best","buzz","build","builders","cash","chat","church","city","claims","clinic","clothing","club","coach","codes","coffee","community","company","computer","construction","contact","contractors","cool","country","credit","dance","deals","delivery","dental","dentist","digital","direct","directory","discount","doctor","download","education","energy","engineer","engineering","enterprises","equipment","events","expert","exposed","express","family","fashion","finance","financial","fit","fitness","food","foundation","fund","furniture","games","gift","glass","gold","graphics","group","guide","guru","haus","healthcare","help","holdings","holiday","home","host","house","icu","immo","industries","ink","insurance","international","investments","jewelry","kitchen","land","lawyer","legal","life","lighting","limited","limo","link","live","loan","loans","lol","love","ltd","management","market","marketing","marketplace","mba","media","medical","money","mortgage","movie","music","network","ninja","parts","party","photo","photography","photos","pics","pictures","plumbing","plus","press","productions","properties","pub","quest","radio","recipes","reviews","rip","run","sale","salon","school","science","security","services","shoes","shop","show","singles","soccer","software","solar","solutions","space","sport","store","studio","style","supplies","supply","support","surgery","systems","taxi","team","tech","technology","theatre","tickets","today","tools","tours","town","trade","training","transfer","travel","tv","university","vacations","ventures","vet","video","villas","vision","vote","voyage","watch","website","wedding","wiki","win","work","works","world","xxx","zone","gay","ltda","health","hiphop","fail","flowers","forsale","free","garden","gives","giving","golf","loan","map","mom","organic","place","play","promo","review","rodeo","show","shows","sky","soccer","social","spa","style","sucks","survey","tech","tires","top","vote","wtf","vip","hey","kiwi","earth",
// common ccTLDs (two-letter)
"ac","ad","ae","af","ag","ai","al","am","ao","aq","ar","as","at","au","aw","ax","az","ba","bb","bd","be","bf","bg","bh","bi","bj","bm","bn","bo","br","bs","bt","bv","bw","by","bz","ca","cc","cd","cf","cg","ch","ci","ck","cl","cm","cn","co","cr","cu","cv","cw","cx","cy","cz","de","dj","dk","dm","do","dz","ec","ee","eg","er","es","et","eu","fi","fj","fk","fm","fo","fr","ga","gb","gd","ge","gf","gg","gh","gi","gl","gm","gn","gp","gq","gr","gs","gt","gu","gw","gy","hk","hm","hn","hr","ht","hu","id","ie","il","im","in","io","iq","ir","is","it","je","jm","jo","jp","ke","kg","kh","ki","km","kn","kp","kr","kw","ky","kz","la","lb","lc","li","lk","lr","ls","lt","lu","lv","ly","ma","mc","md","me","mg","mh","mk","ml","mm","mn","mo","mp","mq","mr","ms","mt","mu","mv","mw","mx","my","mz","na","nc","ne","nf","ng","ni","nl","no","np","nr","nu","nz","om","pa","pe","pf","pg","ph","pk","pl","pm","pn","pr","ps","pt","pw","py","qa","re","ro","rs","ru","rw","sa","sb","sc","sd","se","sg","sh","si","sj","sk","sl","sm","sn","so","sr","ss","st","sv","sx","sy","sz","tc","td","tf","tg","th","tj","tk","tl","tm","tn","to","tr","tt","tv","tw","tz","ua","ug","uk","us","uy","uz","va","vc","ve","vg","vi","vn","vu","wf","ws","ye","yt","za","zm","zw",
]);
async function resolve(host, { endpoint, collision_policy } = {}) {
if (!host || !host.includes(".")) return null;
const name = String(host).toLowerCase();
// Collision gate — only skip ICANN TLDs when icann-wins is set (the default).
const tld = name.split(".").pop();
if ((collision_policy || "icann-wins") === "icann-wins" && ICANN_TLDS.has(tld)) return null;
// BNS TLDs are already handled by the normal BNS path upstream; HNS adapter
// runs for every other dotted host. .eth / .bit go through their own adapters
// first, so by the time we get here they've been eliminated.
if (tld === "eth" || tld === "bit") return null;
const cached = CACHE.get(name);
if (cached && cached.exp > Date.now()) return cached.result;
const gw = (endpoint || "https://hns.to").replace(/\/+$/, "").replace(/^https?:\/\//, "");
const url = `https://${name}.${gw}`;
const result = { url, protocol: "handshake", gateway: gw };
CACHE.set(name, { result, exp: Date.now() + CACHE_TTL_MS });
return result;
}
function clearCache() { CACHE.clear(); }
module.exports = { resolve, clearCache, ICANN_TLDS };

View file

@ -0,0 +1,99 @@
"use strict";
// Namecoin resolver — .bit names via a configured Namecoin JSON-RPC endpoint.
// Reads the Namecoin domain record format (dot-bit spec) and follows an
// `alias` or `translate` field to a clearnet host when present. Pure IP
// records aren't followed yet — rendering those cleanly requires in-browser
// DNS override + TLS pinning, which lives in a later phase.
//
// Namecoin has no public free RPC (unlike publicnode for Ethereum), so the
// user must configure their own RPC URL in Settings › Names & Certificates
// › Namecoin › Advanced. Common sources: a self-run namecoind, Zeronet's
// name-lookup gateway, or a shared RPC from the Namecoin community.
//
// Namecoin record format (JSON, from name_show "d/<name>"):
// {"ip":"192.0.2.1", "ip6":"...", "translate":"example.com",
// "alias":"other.bit", "map":{"www":{"ip":"..."}}, ...}
// Spec: https://wiki.namecoin.org/index.php?title=Domain_Name_Specification_2.0
const POS_TTL_MS = 10 * 60 * 1000;
const NEG_TTL_MS = 60 * 1000;
const RPC_TIMEOUT_MS = 5000;
const CACHE = new Map();
async function rpcCall(rpc, method, params) {
const body = { jsonrpc: "1.0", id: "theseus", method, params };
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(new Error("Namecoin RPC timeout")), RPC_TIMEOUT_MS);
try {
const res = await fetch(rpc, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
signal: ctrl.signal,
});
if (!res.ok && res.status !== 500) throw new Error(`Namecoin RPC HTTP ${res.status}`);
const json = await res.json();
if (json.error) throw new Error(`Namecoin RPC error: ${json.error.message || JSON.stringify(json.error)}`);
return json.result;
} finally { clearTimeout(t); }
}
// Follow one hop of a dot-bit `alias` chain. `alias` points to another
// Namecoin name (like `example.bit`); `translate` points to a clearnet host.
// We walk at most 5 aliases to avoid loops.
async function readRecord(rpc, name, hops = 0) {
if (hops > 5) throw new Error("alias loop");
const key = `d/${name.replace(/\.bit$/i, "")}`;
const row = await rpcCall(rpc, "name_show", [key]);
if (!row || !row.value) throw new Error("name not registered");
let rec; try { rec = JSON.parse(row.value); } catch { throw new Error("record is not JSON"); }
if (rec.alias && typeof rec.alias === "string") {
// alias is another .bit name
const next = rec.alias.replace(/\.bit$/i, "");
return readRecord(rpc, next, hops + 1);
}
return rec;
}
async function resolve(host, { rpc } = {}) {
if (!host) return null;
const name = String(host).toLowerCase();
if (!name.endsWith(".bit")) return null;
const cached = CACHE.get(name);
if (cached && cached.exp > Date.now()) return cached.result;
if (!rpc) {
const result = { error: "no RPC URL configured (Settings › Names & Certificates › Namecoin › Advanced)" };
CACHE.set(name, { result, exp: Date.now() + NEG_TTL_MS });
return result;
}
try {
const rec = await readRecord(rpc, name);
// Prefer translate (clearnet redirect) → navigate there.
if (rec.translate && typeof rec.translate === "string") {
const dest = rec.translate.replace(/^https?:\/\//, "");
const url = `https://${dest}`;
const result = { url, protocol: "namecoin", kind: "translate", target: dest };
CACHE.set(name, { result, exp: Date.now() + POS_TTL_MS });
return result;
}
// Pure IP records: not followed (needs host-rules + cert handling).
if (rec.ip || rec.ip6) {
const result = { error: "pure IP records not supported yet; add a translate= field or wait for IP-record support" };
CACHE.set(name, { result, exp: Date.now() + NEG_TTL_MS });
return result;
}
const result = { error: "no translate/alias/ip record" };
CACHE.set(name, { result, exp: Date.now() + NEG_TTL_MS });
return result;
} catch (err) {
const result = { error: String(err?.message || err) };
CACHE.set(name, { result, exp: Date.now() + NEG_TTL_MS });
return result;
}
}
function clearCache() { CACHE.clear(); }
module.exports = { resolve, clearCache };

50
main.js
View file

@ -888,7 +888,7 @@ const DEFAULT_EXTRA_RESOLVERS = {
resolvers: {
ens: { enabled: false, rpc: "https://ethereum-rpc.publicnode.com", gateway: "https://ipfs.io" },
namecoin: { enabled: false, rpc: "" },
handshake: { enabled: false, endpoint: "https://hnsrecursive.easyhandshake.com", collision_policy: "icann-wins" },
handshake: { enabled: false, endpoint: "https://hns.to", collision_policy: "icann-wins" },
sns: { enabled: false, rpc: "https://api.mainnet-beta.solana.com" },
unstoppable: { enabled: false, endpoint: "https://resolve.unstoppabledomains.com" },
},
@ -5784,6 +5784,19 @@ async function tryExtraResolvers(t, id, host, rest) {
const resolvers = cfg?.resolvers || {};
const h = String(host || "").toLowerCase();
// Shared helper: load the gateway URL + rest into the tab, update prov,
// and return true so the caller knows to stop looking at later adapters.
async function navigateToExtra(result, kind, extraProv) {
const path = rest && rest !== "/" ? rest : "/";
const target = result.url + (path === "/" ? "" : path);
t.url = target;
t.prov = { host: h, kind, ...(extraProv || {}) };
if (isActiveId(id)) pushNav(t.prov);
emitTabs();
await t.view.webContents.loadURL(target).catch(() => {});
return true;
}
// ENS — .eth → ENS registry → content hash → IPFS/IPNS gateway.
if (h.endsWith(".eth") && resolvers.ens?.enabled) {
try {
@ -5792,21 +5805,32 @@ async function tryExtraResolvers(t, id, host, rest) {
rpc: resolvers.ens.rpc,
gateway: resolvers.ens.gateway,
});
if (result && result.url) {
const path = rest && rest !== "/" ? rest : "/";
// Path-based IPFS gateways accept /ipfs/<cid>/<path>. Keep the query
// string intact if the caller carried one through `rest`.
const target = result.url + (path === "/" ? "" : path);
t.url = target;
t.prov = { host: h, kind: "ens", protocol: result.protocol, cid: result.cid, uri: result.uri };
if (isActiveId(id)) pushNav(t.prov);
emitTabs();
await t.view.webContents.loadURL(target).catch(() => {});
return true;
}
if (result && result.url) return navigateToExtra(result, "ens", { protocol: result.protocol, cid: result.cid, uri: result.uri });
} catch (e) { console.warn("[ens] resolve failed:", e?.message); }
}
// Namecoin — .bit → name_show "d/<name>" on the configured RPC → follow
// translate/alias to a clearnet host.
if (h.endsWith(".bit") && resolvers.namecoin?.enabled) {
try {
const nmc = require("./lib/resolvers/namecoin.cjs");
const result = await nmc.resolve(h, { rpc: resolvers.namecoin.rpc });
if (result && result.url) return navigateToExtra(result, "namecoin", { target: result.target });
} catch (e) { console.warn("[namecoin] resolve failed:", e?.message); }
}
// Handshake — route non-ICANN TLDs through hns.to (or configured gateway).
if (resolvers.handshake?.enabled) {
try {
const hns = require("./lib/resolvers/handshake.cjs");
const result = await hns.resolve(h, {
endpoint: resolvers.handshake.endpoint,
collision_policy: resolvers.handshake.collision_policy,
});
if (result && result.url) return navigateToExtra(result, "handshake", { gateway: result.gateway });
} catch (e) { console.warn("[handshake] resolve failed:", e?.message); }
}
return false;
}

View file

@ -1,6 +1,6 @@
{
"name": "theseus-navigator",
"version": "0.3.97",
"version": "0.3.98",
"license": "MPL-2.0",
"description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.",
"author": "Silent Mode",

View file

@ -493,7 +493,7 @@
<div class="row">
<div class="txt">
<div class="t">Namecoin</div>
<div class="d"><i>.bit</i> → DNS record from the Namecoin blockchain. <span class="muted" id="rvNotWiredNmc">Not wired yet — coming in 0.3.97.</span></div>
<div class="d"><i>.bit</i> → record from the Namecoin blockchain; <code>translate</code> or <code>alias</code> redirects are followed. <b>Needs an RPC URL</b> — Namecoin has no public free endpoint; set yours in Advanced.</div>
<div class="acts"><details class="adv"><summary>Advanced</summary>
<label>Namecoin JSON-RPC URL</label><input type="text" id="rvNmcRpc" placeholder="http://user:pass@127.0.0.1:8336">
</details></div>
@ -504,9 +504,9 @@
<div class="row">
<div class="txt">
<div class="t">Handshake (HNS)</div>
<div class="d">User-registered TLDs on the Handshake chain. Handshake auctioned many 3-letter and ICANN-style TLDs on-chain, so collisions are common. <span class="muted" id="rvNotWiredHns">Not wired yet — coming in 0.3.97.</span></div>
<div class="d">User-registered TLDs on the Handshake chain, served through the public <code>hns.to</code> web gateway. The gateway proxies the request to Handshake's own resolver and serves the on-chain content. Handshake auctioned many 3-letter and ICANN-style TLDs on-chain, so the default collision policy keeps ICANN TLDs in clearnet.</div>
<div class="acts"><details class="adv"><summary>Advanced</summary>
<label>Handshake resolver endpoint</label><input type="text" id="rvHnsEndpoint" placeholder="https://hnsrecursive.easyhandshake.com">
<label>Handshake gateway</label><input type="text" id="rvHnsEndpoint" placeholder="https://hns.to">
<label>Collision policy</label>
<select id="rvHnsCollisionPolicy">
<option value="icann-wins">ICANN wins (safer; HNS only resolves TLDs the DNS root doesn't delegate)</option>