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:
parent
e4a9a2cea9
commit
208a192fc9
7 changed files with 244 additions and 15 deletions
|
|
@ -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." },
|
||||
|
|
|
|||
|
|
@ -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
83
lib/resolvers/sns.cjs
Normal 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 };
|
||||
95
lib/resolvers/unstoppable.cjs
Normal file
95
lib/resolvers/unstoppable.cjs
Normal 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
48
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");
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue