diff --git a/home.html b/home.html index cc89cd7c..97f8d2b7 100644 --- a/home.html +++ b/home.html @@ -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." }, diff --git a/lib/resolvers/handshake.cjs b/lib/resolvers/handshake.cjs index be39bce3..46df2527 100644 --- a/lib/resolvers/handshake.cjs +++ b/lib/resolvers/handshake.cjs @@ -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; diff --git a/lib/resolvers/sns.cjs b/lib/resolvers/sns.cjs new file mode 100644 index 00000000..4a9fbd59 --- /dev/null +++ b/lib/resolvers/sns.cjs @@ -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/ → { s: "ok", result: "
" } +// or { s: "error", result: "..." } +// GET /record//url → { s: "ok", result: "https://…" } +// GET /record//ipfs → { s: "ok", result: "" } +// GET /record//arwv → { s: "ok", result: "" } +// +// 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 }; diff --git a/lib/resolvers/unstoppable.cjs b/lib/resolvers/unstoppable.cjs new file mode 100644 index 00000000..f40c3fba --- /dev/null +++ b/lib/resolvers/unstoppable.cjs @@ -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/. +// Response shape (as of 2026-10): +// { records: { +// "browser.redirect_url": "https://…", +// "ipfs.html.value": "", +// "dweb.ipfs.hash": "", +// ... +// }, +// 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 }; diff --git a/main.js b/main.js index e26588b1..0f80d985 100644 --- a/main.js +++ b/main.js @@ -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"); diff --git a/package.json b/package.json index 1dcc02df..e329cea0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/settings.html b/settings.html index 8fc1c5db..e128fed5 100644 --- a/settings.html +++ b/settings.html @@ -520,9 +520,10 @@
Solana Name Service (SNS)
-
.sol → owner record from the Solana name registry. Not wired yet — coming in 0.3.98.
+
.sol → 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.
Advanced - + +
@@ -531,10 +532,11 @@
Unstoppable Domains
-
.crypto, .nft, .wallet, .bitcoin, .blockchain, .dao, .888, .polygon, .unstoppable, and more. -
⚠ .x also exists on Unstoppable — BNS wins on .x, so a Sirius registration always takes precedence. Not wired yet — coming in 0.3.98.
+
.crypto, .nft, .wallet, .bitcoin, .blockchain, .dao, .888, .polygon, .unstoppable, and more. Follows browser.redirect_url first, then IPFS records. +
⚠ .x also exists on Unstoppable — BNS wins on .x, so a Sirius registration always takes precedence (this adapter skips .x entirely).
Advanced +
@@ -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"], ];