Theseus 0.3.99: SNS + Unstoppable adapters, Hermes moves to the sidebar

Two more resolvers under Settings › Names & Certificates. SNS — .sol names
resolve through Bonfida's public SDK proxy, preferring an explicit URL
record over an IPFS record (served via a configurable IPFS gateway).
Unstoppable Domains — the full UD TLD roster (.crypto, .nft, .wallet,
.bitcoin, .blockchain, .dao, …) through resolve.unstoppabledomains.com;
browser.redirect_url takes precedence over an ipfs record. .x stays with
BNS — the UD adapter explicitly skips it so a Sirius registration can
never be shadowed by an Unstoppable one.

Both proxies are Cloudflare-fronted; users on blocked IPs can swap the
endpoint in Advanced. The handshake adapter's skip-list grows to cover
.sol and every UD TLD so a user with HNS + UD + SNS all enabled gets the
specific adapter, never the generic hns.to gateway, for those names.

Hermes.X moves from the home page's Silent Mode card grid to the Theseus
left-sidebar quick-link strip, slotting in right after Proton Mail. The
default list grows to 6 entries; existing profiles pick it up through the
LATER_DEFAULTS one-time offer (same mechanism Proton used in 0.3.88).
Users who still have Hermes on their custom home page can remove it manually.
This commit is contained in:
Local Dev 2026-10-21 19:12:19 +03:00
parent e4a9a2cea9
commit 208a192fc9
7 changed files with 244 additions and 15 deletions

View file

@ -327,7 +327,6 @@
{ title: "Sirius.X", url: "https://sirius.x/", brand: "sirius", sub: "Register and manage BCDN names." },
{ title: "Hephaestus.X", url: "https://hephaestus.x/", brand: "hephaestus", sub: "The forge — Silent Mode's code host." },
{ title: "Prometheus.X", url: "https://prometheus.x/", brand: "prometheus", sub: "Decentralized app marketplace." },
{ title: "Hermes.X", url: "https://hermes.x/", brand: "hermes", sub: "E2E-encrypted messaging over Nostr." },
{ title: "Helios.X", url: "https://helios.x/", brand: "helios", sub: "Search engine for the decentralized web." },
{ title: "Ariadne.X", url: "https://ariadne.x/", brand: "ariadne", sub: "BNS resolver — read .x and .bch names." },
{ title: "Aegis.X", url: "https://aegis.x/", brand: "aegis", sub: "Multi-chain wallet, derived from the vault." },

View file

@ -38,10 +38,20 @@ async function resolve(host, { endpoint, collision_policy } = {}) {
// 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;
// Other extra-resolver TLDs run before HNS in tryExtraResolvers — return
// null here so Handshake doesn't shadow them when their toggle is off and
// the user's intent was clearnet (e.g. a .crypto name that's not registered
// on UD should 404 clearnet, not get hijacked into HNS).
const CLAIMED = new Set([
"eth", "bit", "sol",
// Unstoppable Domains TLDs
"crypto","nft","wallet","bitcoin","blockchain","dao","888","polygon",
"klever","hi","kresus","anime","manga","go","unstoppable","pudgy",
"binanceus","austin","bitget","tball","secret","farms","raiin","stepn",
"emir","smokey","lfg","pog","witg","metropolis","wrkx","benji","altimist",
"ethermail",
]);
if (CLAIMED.has(tld)) return null;
const cached = CACHE.get(name);
if (cached && cached.exp > Date.now()) return cached.result;

83
lib/resolvers/sns.cjs Normal file
View file

@ -0,0 +1,83 @@
"use strict";
// Solana Name Service resolver — .sol names via Bonfida's public SDK proxy.
// SNS stores URL and IPFS records on-chain; the proxy exposes them as a
// REST endpoint so Theseus doesn't need the Solana web3.js stack bundled in.
//
// Proxy responses (as of 2026-10):
// GET /resolve/<name> → { s: "ok", result: "<address or url>" }
// or { s: "error", result: "..." }
// GET /record/<name>/url → { s: "ok", result: "https://…" }
// GET /record/<name>/ipfs → { s: "ok", result: "<cid>" }
// GET /record/<name>/arwv → { s: "ok", result: "<arweave-tx>" }
//
// We prefer URL (clearnet redirect) over IPFS (gateway) over Arweave
// (gateway) because URL is explicit intent and the user's gateway choice
// shouldn't matter for a plain redirect.
//
// If the user's IP is Cloudflare-blocked for the default proxy, they can
// paste any Solana RPC + sns-sdk-proxy compatible endpoint into Advanced.
const POS_TTL_MS = 10 * 60 * 1000;
const NEG_TTL_MS = 60 * 1000;
const RPC_TIMEOUT_MS = 5000;
const CACHE = new Map();
async function proxyGet(base, path) {
const url = base.replace(/\/+$/, "") + path;
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(new Error("SNS proxy timeout")), RPC_TIMEOUT_MS);
try {
const res = await fetch(url, {
method: "GET",
headers: { "accept": "application/json", "user-agent": "Theseus/0.3.99 (silentmode.st)" },
signal: ctrl.signal,
});
if (!res.ok) throw new Error(`SNS HTTP ${res.status}`);
const json = await res.json();
if (json?.s === "ok" && json.result) return String(json.result);
return null; // no record / name not registered
} finally { clearTimeout(t); }
}
async function resolve(host, { rpc, gateway } = {}) {
if (!host) return null;
const name = String(host).toLowerCase();
if (!name.endsWith(".sol") || name === ".sol" || name === "sol") return null;
const cached = CACHE.get(name);
if (cached && cached.exp > Date.now()) return cached.result;
// "rpc" here is the SNS proxy base URL (not a Solana JSON-RPC); naming
// is for consistency with the other adapters.
const base = rpc || "https://sns-sdk-proxy.bonfida.workers.dev";
const gw = (gateway || "https://ipfs.io").replace(/\/+$/, "");
const bareName = name.replace(/\.sol$/i, "");
try {
// URL takes precedence (explicit redirect intent).
const urlRec = await proxyGet(base, `/record/${encodeURIComponent(bareName)}.sol/url`).catch(() => null);
if (urlRec) {
const u = /^https?:\/\//i.test(urlRec) ? urlRec : `https://${urlRec}`;
const result = { url: u, protocol: "sns", kind: "url" };
CACHE.set(name, { result, exp: Date.now() + POS_TTL_MS });
return result;
}
// IPFS fallback.
const ipfsRec = await proxyGet(base, `/record/${encodeURIComponent(bareName)}.sol/ipfs`).catch(() => null);
if (ipfsRec) {
const result = { url: `${gw}/ipfs/${ipfsRec}`, protocol: "sns", kind: "ipfs", cid: ipfsRec };
CACHE.set(name, { result, exp: Date.now() + POS_TTL_MS });
return result;
}
const result = { error: "no URL or IPFS record on this .sol name" };
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 };

View file

@ -0,0 +1,95 @@
"use strict";
// Unstoppable Domains resolver — the whole UD TLD roster except .x, which
// belongs to BNS/Sirius. "BNS wins on .x" is the design rule: a Sirius
// registration must always resolve to the Sirius record, so this adapter
// treats .x as not-ours and returns null, letting the chain fall through.
//
// Resolution: HTTPS GET to resolve.unstoppabledomains.com/domains/<name>.
// Response shape (as of 2026-10):
// { records: {
// "browser.redirect_url": "https://…",
// "ipfs.html.value": "<cid>",
// "dweb.ipfs.hash": "<cid>",
// ...
// },
// meta: { owner, resolver, blockchain, networkId } }
//
// Precedence inside the record set: browser.redirect_url > ipfs.html.value
// > dweb.ipfs.hash. Users who want a different gateway swap it in Advanced.
const POS_TTL_MS = 10 * 60 * 1000;
const NEG_TTL_MS = 60 * 1000;
const RPC_TIMEOUT_MS = 5000;
const CACHE = new Map();
// TLDs on Unstoppable's registries (crypto + .nft + the Polygon-era batch).
// Trimmed, not exhaustive — Unstoppable keeps adding new ones; a user whose
// TLD isn't here can toggle Handshake "hns-wins" or add a per-host override
// later. We explicitly skip .x — BNS owns .x in Theseus.
const UD_TLDS = new Set([
"crypto", "nft", "wallet", "bitcoin", "blockchain", "dao",
"888", "polygon", "klever", "hi", "kresus", "anime", "manga",
"go", "unstoppable", "pudgy", "binanceus", "austin", "bitget",
"tball", "secret", "farms", "raiin", "stepn", "emir", "smokey",
"lfg", "pog", "witg", "metropolis", "wrkx", "benji", "altimist",
"ethermail",
]);
async function udFetch(base, name) {
const url = base.replace(/\/+$/, "") + "/domains/" + encodeURIComponent(name);
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(new Error("Unstoppable timeout")), RPC_TIMEOUT_MS);
try {
const res = await fetch(url, {
method: "GET",
headers: { "accept": "application/json", "user-agent": "Theseus/0.3.99 (silentmode.st)" },
signal: ctrl.signal,
});
if (!res.ok) throw new Error(`Unstoppable HTTP ${res.status}`);
return await res.json();
} finally { clearTimeout(t); }
}
async function resolve(host, { endpoint, gateway } = {}) {
if (!host || !host.includes(".")) return null;
const name = String(host).toLowerCase();
const tld = name.split(".").pop();
if (!UD_TLDS.has(tld)) return null; // not an Unstoppable TLD
// BNS owns .x — never shadow a Sirius registration with an Unstoppable one.
if (tld === "x") return null;
const cached = CACHE.get(name);
if (cached && cached.exp > Date.now()) return cached.result;
const base = endpoint || "https://resolve.unstoppabledomains.com";
const gw = (gateway || "https://ipfs.io").replace(/\/+$/, "");
try {
const data = await udFetch(base, name);
const records = data?.records || {};
const redirect = records["browser.redirect_url"];
if (redirect && typeof redirect === "string") {
const u = /^https?:\/\//i.test(redirect) ? redirect : `https://${redirect}`;
const result = { url: u, protocol: "unstoppable", kind: "redirect", target: u };
CACHE.set(name, { result, exp: Date.now() + POS_TTL_MS });
return result;
}
const ipfs = records["ipfs.html.value"] || records["dweb.ipfs.hash"];
if (ipfs && typeof ipfs === "string") {
const result = { url: `${gw}/ipfs/${ipfs}`, protocol: "unstoppable", kind: "ipfs", cid: ipfs };
CACHE.set(name, { result, exp: Date.now() + POS_TTL_MS });
return result;
}
const result = { error: "no browser.redirect_url or ipfs 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, UD_TLDS };

48
main.js
View file

@ -586,6 +586,7 @@ const SETTINGS_DEFAULTS = {
quickLinksShow: true,
quickLinks: [
{ id: "proton", url: "https://mail.proton.me/", title: "Proton Mail" },
{ id: "hermes", url: "https://hermes.x/", title: "Hermes" },
{ id: "telegram", url: "https://web.telegram.org/k/", title: "Telegram" },
{ id: "whatsapp", url: "https://web.whatsapp.com/", title: "WhatsApp" },
{ id: "x", url: "https://x.com/", title: "X" },
@ -674,7 +675,7 @@ function loadSettings() {
// itself). quickLinksOffered remembers it, so one the user removes stays
// removed. Kept out of SETTINGS_DEFAULTS so an old profile reads as "never offered".
try {
const LATER_DEFAULTS = ["proton"]; // added in 0.3.88
const LATER_DEFAULTS = ["proton", "hermes"]; // proton 0.3.88 · hermes 0.3.99
const offered = Array.isArray(settings.quickLinksOffered) ? settings.quickLinksOffered.slice() : [];
const have = Array.isArray(settings.quickLinks) ? settings.quickLinks : null;
let changed = false;
@ -682,8 +683,18 @@ function loadSettings() {
if (offered.includes(id)) continue;
offered.push(id); changed = true;
const def = SETTINGS_DEFAULTS.quickLinks.find((L) => L.id === id);
// Proton Mail goes to the top of the web apps; any later one at the end.
if (have && def && !have.some((L) => L && L.id === id)) { if (id === "proton") have.unshift({ ...def }); else have.push({ ...def }); }
// Proton Mail goes to the top of the web apps; Hermes slots in right
// after Proton (keeping Silent-Mode-owned messaging near the top); any
// other later one at the end.
if (have && def && !have.some((L) => L && L.id === id)) {
if (id === "proton") have.unshift({ ...def });
else if (id === "hermes") {
const after = have.findIndex((L) => L && L.id === "proton");
if (after >= 0) have.splice(after + 1, 0, { ...def });
else have.unshift({ ...def });
}
else have.push({ ...def });
}
}
if (changed) { settings.quickLinksOffered = offered; saveSettings(); }
} catch (e) { console.warn("quickLinks offer:", e?.message); }
@ -889,8 +900,8 @@ const DEFAULT_EXTRA_RESOLVERS = {
ens: { enabled: false, rpc: "https://ethereum-rpc.publicnode.com", gateway: "https://ipfs.io" },
namecoin: { enabled: false, rpc: "" },
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" },
sns: { enabled: false, rpc: "https://sns-sdk-proxy.bonfida.workers.dev", gateway: "https://ipfs.io" },
unstoppable: { enabled: false, endpoint: "https://resolve.unstoppabledomains.com", gateway: "https://ipfs.io" },
},
ca_bundles: {
ru_national: { enabled: false, scope_tlds: ["ru", "рф", "gov.ru", "online.ru"] },
@ -5819,7 +5830,34 @@ async function tryExtraResolvers(t, id, host, rest) {
} catch (e) { console.warn("[namecoin] resolve failed:", e?.message); }
}
// SNS — .sol → Bonfida SDK proxy → URL/IPFS record on Solana.
if (h.endsWith(".sol") && resolvers.sns?.enabled) {
try {
const sns = require("./lib/resolvers/sns.cjs");
const result = await sns.resolve(h, {
rpc: resolvers.sns.rpc,
gateway: resolvers.sns.gateway,
});
if (result && result.url) return navigateToExtra(result, "sns", { kind: result.kind, cid: result.cid });
} catch (e) { console.warn("[sns] resolve failed:", e?.message); }
}
// Unstoppable Domains — the UD TLD set (except .x, which belongs to BNS).
if (resolvers.unstoppable?.enabled) {
try {
const ud = require("./lib/resolvers/unstoppable.cjs");
const result = await ud.resolve(h, {
endpoint: resolvers.unstoppable.endpoint,
gateway: resolvers.unstoppable.gateway,
});
if (result && result.url) return navigateToExtra(result, "unstoppable", { kind: result.kind, cid: result.cid, target: result.target });
} catch (e) { console.warn("[unstoppable] resolve failed:", e?.message); }
}
// Handshake — route non-ICANN TLDs through hns.to (or configured gateway).
// Runs last because it has the broadest match (every non-ICANN TLD) and we
// want the more-specific adapters (ENS .eth, Namecoin .bit, SNS .sol,
// Unstoppable's own TLD list) to win over the generic HNS gateway.
if (resolvers.handshake?.enabled) {
try {
const hns = require("./lib/resolvers/handshake.cjs");

View file

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

View file

@ -520,9 +520,10 @@
<div class="row">
<div class="txt">
<div class="t">Solana Name Service (SNS)</div>
<div class="d"><i>.sol</i> → owner record from the Solana name registry. <span class="muted" id="rvNotWiredSns">Not wired yet — coming in 0.3.98.</span></div>
<div class="d"><i>.sol</i> → URL or IPFS record on the Solana name registry, read through Bonfida's public SDK proxy. The proxy sits behind Cloudflare; if your network is blocked, paste an alternative in Advanced.</div>
<div class="acts"><details class="adv"><summary>Advanced</summary>
<label>Solana RPC URL</label><input type="text" id="rvSnsRpc" placeholder="https://api.mainnet-beta.solana.com">
<label>SNS proxy URL</label><input type="text" id="rvSnsRpc" placeholder="https://sns-sdk-proxy.bonfida.workers.dev">
<label>IPFS gateway (for ipfs records)</label><input type="text" id="rvSnsGateway" placeholder="https://ipfs.io">
</details></div>
</div>
<label class="sw"><input type="checkbox" id="rvSnsOn"><span class="track"><span class="knob"></span></span></label>
@ -531,10 +532,11 @@
<div class="row">
<div class="txt">
<div class="t">Unstoppable Domains</div>
<div class="d"><i>.crypto</i>, <i>.nft</i>, <i>.wallet</i>, <i>.bitcoin</i>, <i>.blockchain</i>, <i>.dao</i>, <i>.888</i>, <i>.polygon</i>, <i>.unstoppable</i>, and more.
<br>⚠ <i>.x</i> also exists on Unstoppable — BNS wins on <i>.x</i>, so a Sirius registration always takes precedence. <span class="muted" id="rvNotWiredUd">Not wired yet — coming in 0.3.98.</span></div>
<div class="d"><i>.crypto</i>, <i>.nft</i>, <i>.wallet</i>, <i>.bitcoin</i>, <i>.blockchain</i>, <i>.dao</i>, <i>.888</i>, <i>.polygon</i>, <i>.unstoppable</i>, and more. Follows <code>browser.redirect_url</code> first, then IPFS records.
<br>⚠ <i>.x</i> also exists on Unstoppable — BNS wins on <i>.x</i>, so a Sirius registration always takes precedence (this adapter skips <i>.x</i> entirely).</div>
<div class="acts"><details class="adv"><summary>Advanced</summary>
<label>Resolver endpoint</label><input type="text" id="rvUdEndpoint" placeholder="https://resolve.unstoppabledomains.com">
<label>IPFS gateway (for ipfs records)</label><input type="text" id="rvUdGateway" placeholder="https://ipfs.io">
</details></div>
</div>
<label class="sw"><input type="checkbox" id="rvUdOn"><span class="track"><span class="knob"></span></span></label>
@ -3261,8 +3263,10 @@
["rvHnsCollisionPolicy", "resolvers.handshake.collision_policy", "text"],
["rvSnsOn", "resolvers.sns.enabled", "bool"],
["rvSnsRpc", "resolvers.sns.rpc", "text"],
["rvSnsGateway", "resolvers.sns.gateway", "text"],
["rvUdOn", "resolvers.unstoppable.enabled","bool"],
["rvUdEndpoint", "resolvers.unstoppable.endpoint","text"],
["rvUdGateway", "resolvers.unstoppable.gateway","text"],
["caRuOn", "ca_bundles.ru_national.enabled","bool"],
["caRuScope", "ca_bundles.ru_national.scope_tlds","csv"],
];