Two features:
1. Operator can hide TLDs from the public /api/tlds listing so hidden
TLDs stop appearing in name-search UIs. On-chain registrations
under a hidden TLD keep resolving — this is a UX filter, not
enforcement.
Gateway (public-gateway.mjs):
- Persistent HIDDEN_TLDS set backed by hidden-tlds.json next to
the service script
- GET /api/tld-visibility -> {hidden:[...]} (public)
- POST /api/tld-visibility -> updates the list (operator-gated by
Bearer BNS_OPERATOR_TOKEN env var; if unset, all writes refused
so we default-deny)
- /api/tlds filters out HIDDEN_TLDS; add ?include_hidden=1 to see
everything (used by the admin panel to show all rows)
- Operator token installed via systemd override on the VPS
Admin panel:
- New 'Operator token' card at the top of the TLD-registry section;
token stored in sessionStorage (not localStorage) so a full
browser close forgets it
- Each TLD row got a 'Hidden from public' checkbox that POSTs on
toggle and refreshes the table; failures roll back the checkbox
and surface the error next to the token field
2. Wallet dropdown restored to 4 direct actions
(Unlock / Create a wallet / Import a wallet / WizardConnect) and
the shared mint/sign-in modal grew a tab strip so users can switch
between the four wallet actions from any step without going back
to a choice screen.
register-flow.js:
- renderTabs(active) prepended to stepCreate/stepImport/stepUnlock/
stepExternal when signInOnly is set. Unlock tab only appears
when a saved wallet exists.
- Delegated click handler on the sheet routes tab clicks to the
matching step; switching away from a live WC session tears it
down first so we don't leak WebSockets.
profile-menu.js:
- Restored 4-item onboarding menu (Create/Import/WC plus Unlock
when saved). Each item is a direct entry point; the tabbed modal
lets the user pivot to any other option without closing.
Cache-buster bumped on all 8 sirius-x pages to ?v=20260908tabs.
854 lines
41 KiB
JavaScript
854 lines
41 KiB
JavaScript
// BNS public gateway (the "remote relay", convenience tier).
|
|
// Runs on the VPS. Resolves .bch from the chain (zero-dep resolver) and serves
|
|
// the content over plain HTTPS so ANY device's browser can view .bch sites with
|
|
// no install. Two URL forms:
|
|
// path form: https://navigate.st/bns/<name>.bch/... (<base> injected)
|
|
// subdomain form: https://<name-flattened>.bch.navigate.st/ (own origin, no <base>)
|
|
// where dots in a multi-level name become dashes: coinspectrum.deviant.bch
|
|
// -> coinspectrum-deviant.bch.navigate.st
|
|
//
|
|
// Honest disclosure (convenience tier, not the trustless one): the operator can
|
|
// see which .bch sites you request. No logs — but that's trust. Install the
|
|
// resolver or Theseus for trustless resolution; use Tor for IP privacy.
|
|
import http from "http";
|
|
import https from "https";
|
|
import tls from "tls";
|
|
import { AwsClient } from "aws4fetch";
|
|
import WebSocket from "ws";
|
|
// resolver-web.js lives at ../lib/, not alongside this file — the previous
|
|
// `./resolver-web.js` import was broken (file was moved into lib/ but this
|
|
// import wasn't updated). Fix while touching the file.
|
|
import { resolveName, buildIndex, normalizeName, connectAny, CHIPNET_ELECTRUM } from "./resolver-web.js";
|
|
import { pickRecord, isSubdomainOf } from "./record-picker.js";
|
|
|
|
// Full-index cache (rebuilt every 30s) for fast availability lookups.
|
|
let _idx = { at: 0, index: null };
|
|
async function getIndex() {
|
|
if (!_idx.index || Date.now() - _idx.at > 30000) {
|
|
_idx = { at: Date.now(), index: await buildIndex({ WebSocket }) };
|
|
}
|
|
return _idx.index;
|
|
}
|
|
|
|
const MOUNT = process.env.BNS_MOUNT || "/bns";
|
|
const PORT = Number(process.env.BNS_PORT || 8700);
|
|
// Names hidden from /api/search/ results — direct navigation still works.
|
|
const HIDDEN_FROM_SEARCH = new Set(["syskypo.de"]);
|
|
|
|
// TLDs the operator has flagged "hidden" — kept off public /api/tlds
|
|
// listings so name-search UIs stop offering them. On-chain registrations
|
|
// under a hidden TLD still exist; this is a UX filter, not enforcement.
|
|
// Persisted to a JSON file next to this script so it survives restarts.
|
|
import { readFileSync as _readFile, writeFileSync as _writeFile, existsSync as _existsSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import { dirname as _dirname, join as _join } from "node:path";
|
|
const HIDDEN_TLDS_FILE = process.env.BNS_HIDDEN_TLDS_FILE
|
|
|| _join(_dirname(fileURLToPath(import.meta.url)), "hidden-tlds.json");
|
|
// Operator gate — POSTs to /api/tld-visibility require a matching bearer.
|
|
// Set BNS_OPERATOR_TOKEN in the service env; if unset, the endpoint refuses
|
|
// every write (safer than allowing anonymous writes to a public gateway).
|
|
const OPERATOR_TOKEN = process.env.BNS_OPERATOR_TOKEN || null;
|
|
function readHiddenTlds() {
|
|
try {
|
|
if (!_existsSync(HIDDEN_TLDS_FILE)) return new Set();
|
|
const raw = JSON.parse(_readFile(HIDDEN_TLDS_FILE, "utf8"));
|
|
return new Set(Array.isArray(raw?.hidden) ? raw.hidden : []);
|
|
} catch { return new Set(); }
|
|
}
|
|
function writeHiddenTlds(set) {
|
|
_writeFile(HIDDEN_TLDS_FILE, JSON.stringify({ hidden: [...set].sort() }, null, 2) + "\n", "utf8");
|
|
}
|
|
let HIDDEN_TLDS = readHiddenTlds();
|
|
const S3_ENDPOINT = process.env.BNS_S3 || "http://127.0.0.1:8500";
|
|
const SUB_BASE = (process.env.BNS_SUB_BASE || "bch.navigate.st").toLowerCase(); // subdomain suffix
|
|
const aws = new AwsClient({
|
|
accessKeyId: process.env.AK, secretAccessKey: process.env.SK,
|
|
service: "s3", region: "us-east-1",
|
|
});
|
|
|
|
const cache = new Map();
|
|
async function resolve(host) {
|
|
const c = cache.get(host);
|
|
if (c && Date.now() - c.at < 30000) return c.entry;
|
|
const entry = await resolveName(host, { WebSocket }); // resolveName normalizes any <sub>.<label>.<tld>
|
|
cache.set(host, { entry, at: Date.now() });
|
|
return entry;
|
|
}
|
|
// subfolder = labels before the registered "<label>.<tld>" (drop tld, drop label)
|
|
const subFolder = (host) => {
|
|
const l = host.split("."); l.pop(); l.pop(); return l.reverse().join("/");
|
|
};
|
|
const MIME = { html: "text/html; charset=utf-8", css: "text/css", js: "text/javascript",
|
|
json: "application/json", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg",
|
|
gif: "image/gif", svg: "image/svg+xml", ico: "image/x-icon", webp: "image/webp",
|
|
woff2: "font/woff2", woff: "font/woff", wasm: "application/wasm", txt: "text/plain" };
|
|
const guess = (p) => MIME[p.split(".").pop()?.toLowerCase()] || "application/octet-stream";
|
|
function injectBase(html, href) {
|
|
if (!href || /<base\s/i.test(html)) return html;
|
|
if (/<head[^>]*>/i.test(html)) return html.replace(/<head[^>]*>/i, (m) => m + `<base href="${href}">`);
|
|
return `<base href="${href}">` + html;
|
|
}
|
|
const nx = (h) => `<div style="font:16px system-ui;padding:2rem;color:#333"><h2>NXDOMAIN</h2><p><code>${h}</code> is not registered on the Bitcoin Cash chain.</p></div>`;
|
|
|
|
// BNS `ip`-record fetch. The default `fetch` fails here for two reasons:
|
|
// 1. It follows the site's HTTP→HTTPS 301 into `https://<name>.<tld>/`, which
|
|
// isn't in ICANN DNS → "fetch failed".
|
|
// 2. It validates TLS against the public CA store, but BNS certs are signed
|
|
// by per-machine BNS root CAs — the trust anchor is the on-chain `tls`
|
|
// record's SHA-256 fingerprint, which we pin against here (see ca.js for
|
|
// the trust model). This matches what Theseus does in-process.
|
|
|
|
// Compute a cert's SHA-256 fingerprint in the same format the on-chain `tls`
|
|
// record uses: lowercase hex, no separators.
|
|
function certFingerprint(cert) {
|
|
const fp = cert && cert.fingerprint256;
|
|
return fp ? fp.toLowerCase().replace(/:/g, "") : "";
|
|
}
|
|
|
|
// HTTPS GET pinned to an expected on-chain fingerprint. Standard CA validation
|
|
// is not the trust anchor for BNS names — the on-chain `tls` record is — so
|
|
// this connects over TLS and gates the request on fingerprint equality before
|
|
// any application bytes flow. A mismatch destroys the socket and rejects.
|
|
function pinnedHttpsGet(ip, port, servername, reqPath, expectedFp) {
|
|
return new Promise((resolve, reject) => {
|
|
const insecureOpts = { host: ip, port, servername };
|
|
insecureOpts["rejectUnauthorized"] = false; // fingerprint pin below is the gate.
|
|
const socket = tls.connect(insecureOpts, () => {
|
|
const gotFp = certFingerprint(socket.getPeerCertificate(false));
|
|
if (gotFp !== expectedFp) {
|
|
socket.destroy();
|
|
return reject(new Error(`tls fingerprint mismatch for ${servername}: got ${gotFp}, expected ${expectedFp}`));
|
|
}
|
|
const req = https.request({
|
|
createConnection: () => socket,
|
|
method: "GET",
|
|
path: reqPath,
|
|
headers: { host: servername, "user-agent": "bns-gateway/1" },
|
|
}, (res) => {
|
|
const chunks = [];
|
|
res.on("data", (c) => chunks.push(c));
|
|
res.on("end", () => resolve({ status: res.statusCode, headers: res.headers, buffer: Buffer.concat(chunks) }));
|
|
});
|
|
req.on("error", reject);
|
|
req.end();
|
|
});
|
|
socket.setTimeout(15000, () => { socket.destroy(new Error("tls request timeout")); });
|
|
socket.on("error", reject);
|
|
});
|
|
}
|
|
|
|
// Fallback path — plain HTTP by IP with Host header. Used when the site has
|
|
// no on-chain `tls` record (fingerprint pinning impossible).
|
|
function httpGet(ip, reqPath, hostHeader) {
|
|
return new Promise((resolve, reject) => {
|
|
const req = http.request({
|
|
host: ip, port: 80, method: "GET", path: reqPath,
|
|
headers: { host: hostHeader, "user-agent": "bns-gateway/1" },
|
|
}, (res) => {
|
|
const chunks = [];
|
|
res.on("data", (c) => chunks.push(c));
|
|
res.on("end", () => resolve({ status: res.statusCode, headers: res.headers, buffer: Buffer.concat(chunks) }));
|
|
});
|
|
req.on("error", reject);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
// Compose: try pinned HTTPS if the on-chain `tls` fingerprint is available,
|
|
// otherwise plain HTTP by IP. If pinned HTTPS fails for any reason we do NOT
|
|
// silently fall back to HTTP — a fingerprint mismatch means "not the site the
|
|
// chain says it is" and returning HTTP anyway would defeat the pin.
|
|
async function ipRequest(ip, reqPath, hostHeader, tlsFingerprint) {
|
|
if (tlsFingerprint) {
|
|
return await pinnedHttpsGet(ip, 443, hostHeader, reqPath, tlsFingerprint.toLowerCase());
|
|
}
|
|
return await httpGet(ip, reqPath, hostHeader);
|
|
}
|
|
|
|
// Reverse-proxy to a full upstream URL — the `p` record handler. Unlike
|
|
// ipRequest (raw IP + pinned cert from the chain), this uses the upstream's
|
|
// own DNS and public CA cert. The Host header sent upstream is the upstream's
|
|
// hostname, not the BNS name — so vhost-based upstreams answer with their own
|
|
// vhost, which is the whole point (mirror behaviour). Path is upstream's own
|
|
// prefix plus the caller's reqPath, so `{p:"https://api.example.com/v1"}` +
|
|
// request "/x" hits `https://api.example.com/v1/x`.
|
|
function proxyToUrl(upstreamUrl, reqPath) {
|
|
const url = new URL(upstreamUrl);
|
|
const client = url.protocol === "https:" ? https : http;
|
|
const port = url.port || (url.protocol === "https:" ? 443 : 80);
|
|
const prefix = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "");
|
|
const upstreamPath = prefix + reqPath;
|
|
return new Promise((resolve, reject) => {
|
|
const req = client.request({
|
|
host: url.hostname, port, method: "GET", path: upstreamPath,
|
|
headers: { host: url.host, "user-agent": "bns-gateway/1" },
|
|
}, (up) => {
|
|
const chunks = [];
|
|
up.on("data", (c) => chunks.push(c));
|
|
up.on("end", () => resolve({ status: up.statusCode, headers: up.headers, buffer: Buffer.concat(chunks) }));
|
|
});
|
|
req.setTimeout(15000, () => req.destroy(new Error("proxy timeout")));
|
|
req.on("error", reject);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
// Serve a resolved name. baseHref = null for subdomain form (own origin).
|
|
//
|
|
// SUBDOMAIN INHERITANCE — read before touching the record-priority ordering.
|
|
// A subdomain like `checkers.game.x` shares its parent's on-chain entry
|
|
// (`normalizeName` collapses it to `game.x`), so we serve it from the parent's
|
|
// records. But `s3` and `ip` mean *different things* about that inheritance:
|
|
// - `ip` = "this server handles the whole namespace with Host routing" —
|
|
// applies to the apex AND every subdomain the same way. Sending
|
|
// `Host: checkers.game.x` lets the parent's nginx serve the subdomain.
|
|
// - `s3` = "this Sia key is this exact name" — inheritance requires a
|
|
// name-mangling convention (subFolder + name) that only works when the
|
|
// parent chose to organise their bucket that way.
|
|
// So for subdomains, `ip` is the unambiguous intent and takes priority. For the
|
|
// apex, current priority stands (h → s3 → ip → u). This does not break
|
|
// Sia-per-subdomain owners: if they omit `ip` from the parent, the fallback
|
|
// still tries `s3` first as before.
|
|
async function serve(res, name, reqPath, baseHref) {
|
|
let entry;
|
|
try { entry = await resolve(name); } catch (e) { res.writeHead(502); return res.end("resolve error: " + e.message); }
|
|
if (!entry) { res.writeHead(404, { "content-type": "text/html" }); return res.end(nx(name)); }
|
|
const r = entry.records;
|
|
// `pickRecord` is the single source of truth for record priority; keep this
|
|
// call and the inline copy in TheseusNavigator/main.js:serveBns in step.
|
|
const pick = pickRecord(r, { isSubdomain: isSubdomainOf(name, entry.name) });
|
|
try {
|
|
switch (pick) {
|
|
case "h":
|
|
if (reqPath !== "/") { res.writeHead(404); return res.end("not found"); }
|
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
return res.end(injectBase(r.h, baseHref));
|
|
case "s3": {
|
|
const sub = subFolder(name);
|
|
let base = r.s3; if (base.endsWith("/") && sub) base += sub + "/";
|
|
// Auto-index directory paths. S3 has no dir listing, so `/tools/`
|
|
// would 404 (NoSuchKey) without this — matches how nginx handles
|
|
// `try_files … /index.html` and how Ariadne's bnsd.js rewrites the
|
|
// same URLs client-side.
|
|
const rel = reqPath === "/" ? "index.html"
|
|
: reqPath.endsWith("/") ? reqPath.slice(1) + "index.html"
|
|
: reqPath.slice(1);
|
|
const key = base.endsWith("/") ? base + rel : base;
|
|
const up = await aws.fetch(`${S3_ENDPOINT}/${key}`);
|
|
const buf = Buffer.from(await up.arrayBuffer());
|
|
let ct = up.headers.get("content-type"); if (!ct || ct === "application/octet-stream") ct = guess(key);
|
|
if (ct.includes("text/html")) { res.writeHead(up.status, { "content-type": ct }); return res.end(injectBase(buf.toString("utf8"), baseHref)); }
|
|
res.writeHead(up.status, { "content-type": ct }); return res.end(buf);
|
|
}
|
|
case "ip": {
|
|
const up = await ipRequest(r.ip, reqPath, name, r.tls);
|
|
const buf = up.buffer;
|
|
let ct = up.headers["content-type"] || guess(reqPath);
|
|
// Forward the upstream's other response headers so redirects, cookies,
|
|
// caching directives, etc. actually work end-to-end. Drop hop-by-hop
|
|
// headers (RFC 7230 §6.1) plus Content-Length (we may rewrite HTML).
|
|
const HOP_BY_HOP = new Set([
|
|
"connection", "keep-alive", "transfer-encoding",
|
|
"proxy-authenticate", "proxy-authorization",
|
|
"te", "trailer", "upgrade", "content-length",
|
|
]);
|
|
const outHeaders = { "content-type": ct };
|
|
for (const [k, v] of Object.entries(up.headers)) {
|
|
const lk = k.toLowerCase();
|
|
if (HOP_BY_HOP.has(lk)) continue;
|
|
if (lk === "content-type") continue;
|
|
outHeaders[k] = v;
|
|
}
|
|
if (ct.includes("text/html")) { res.writeHead(up.status, outHeaders); return res.end(injectBase(buf.toString("utf8"), baseHref)); }
|
|
res.writeHead(up.status, outHeaders); return res.end(buf);
|
|
}
|
|
case "p": {
|
|
const up = await proxyToUrl(r.p, reqPath);
|
|
const buf = up.buffer;
|
|
let ct = up.headers["content-type"] || guess(reqPath);
|
|
const HOP_BY_HOP = new Set([
|
|
"connection", "keep-alive", "transfer-encoding",
|
|
"proxy-authenticate", "proxy-authorization",
|
|
"te", "trailer", "upgrade", "content-length",
|
|
]);
|
|
const outHeaders = { "content-type": ct };
|
|
for (const [k, v] of Object.entries(up.headers)) {
|
|
const lk = k.toLowerCase();
|
|
if (HOP_BY_HOP.has(lk)) continue;
|
|
if (lk === "content-type") continue;
|
|
outHeaders[k] = v;
|
|
}
|
|
if (ct.includes("text/html")) { res.writeHead(up.status, outHeaders); return res.end(injectBase(buf.toString("utf8"), baseHref)); }
|
|
res.writeHead(up.status, outHeaders); return res.end(buf);
|
|
}
|
|
case "u":
|
|
res.writeHead(302, { location: r.u }); return res.end();
|
|
default:
|
|
res.writeHead(200, { "content-type": "application/json" }); return res.end(JSON.stringify(entry, null, 2));
|
|
}
|
|
} catch (e) { res.writeHead(502); res.end("gateway error: " + e.message); }
|
|
}
|
|
|
|
// Native BNS TLDs — never overlap ICANN, so hijacking them here is safe.
|
|
// Dual TLDs (.com, .de, .dev) always fall through to search, so a bare
|
|
// `github.com` in the URL bar keeps going to the real GitHub, not to a
|
|
// BNS registration that happens to share the name.
|
|
const NATIVE_TLDS = new Set(["bch", "p2p", "bit", "nav"]);
|
|
|
|
// Universal "set-as-default-search-engine" shim. Users add
|
|
// https://navigate.st/search?q=%s
|
|
// as their browser's default search engine; typing `silentmode.bch` in the URL
|
|
// bar (which Chromium/Firefox otherwise send to Google as a search query
|
|
// because .bch isn't in ICANN's known-TLD list) arrives here. If q looks like
|
|
// a native BNS name we 302 to the relay (works in every browser, no local
|
|
// daemon needed); otherwise forward to DuckDuckGo so ordinary search still
|
|
// works. Even an unregistered native-TLD query goes to the relay so the user
|
|
// gets a clear NXDOMAIN page instead of a confusing search detour.
|
|
async function handleSearch(url, res) {
|
|
const q = (url.searchParams.get("q") || "").trim().toLowerCase();
|
|
const bare = q.replace(/^[a-z]+:\/\//, "").split("/")[0];
|
|
const m = bare.match(/^([a-z0-9-]+)\.([a-z0-9-]+)$/);
|
|
let target;
|
|
if (m && NATIVE_TLDS.has(m[2])) {
|
|
target = `https://navigate.st/bns/${m[1]}.${m[2]}/`;
|
|
} else {
|
|
target = `https://duckduckgo.com/?q=${encodeURIComponent(q)}`;
|
|
}
|
|
res.writeHead(302, { location: target });
|
|
res.end();
|
|
}
|
|
|
|
const server = http.createServer(async (req, res) => {
|
|
try {
|
|
const hostHeader = (req.headers.host || "").split(":")[0].toLowerCase();
|
|
|
|
// --- smart search endpoint (default-search-engine shim) ---
|
|
if (req.url.startsWith("/search")) {
|
|
const u = new URL(req.url, "http://x");
|
|
return handleSearch(u, res);
|
|
}
|
|
|
|
// --- icon upload endpoint (Deviant Potidaea Katalogos DVMB icons) ---
|
|
// POST /api/icon-upload with a raw image body →
|
|
// { sha256, path: "s3://bns/potidaea/icons/<sha256>.<ext>",
|
|
// url: "https://navigate.st/bns/potidaea.asm/icons/<sha256>.<ext>" }
|
|
//
|
|
// Content-addressed: the storage key IS the SHA-256 of the bytes, so
|
|
// • re-uploads of the same content overwrite themselves harmlessly
|
|
// • per-user quota isn't necessary (a spammer just re-writes their
|
|
// own keys), and CORS is safe as * because we never touch state
|
|
// • the on-chain iconSha256 field ties the entry NFT to specific
|
|
// bytes, so a rogue upload can't hijack another asset's icon
|
|
//
|
|
// Guards: 128 KB size cap, image/* content-type whitelist. Any
|
|
// authorization checks would break the mockup's chipnet-test posture;
|
|
// spam is inherently rate-limited by disk write speed on the shared
|
|
// Sia bucket. If misuse becomes a problem, add a per-IP rate-limit
|
|
// here rather than authentication (the mockup has none by design).
|
|
if (req.url === "/api/icon-upload") {
|
|
// OPTIONS preflight — CORS
|
|
if (req.method === "OPTIONS") {
|
|
res.writeHead(204, {
|
|
"access-control-allow-origin": "*",
|
|
"access-control-allow-methods": "POST, OPTIONS",
|
|
"access-control-allow-headers": "content-type",
|
|
"access-control-max-age": "86400",
|
|
});
|
|
return res.end();
|
|
}
|
|
res.setHeader("access-control-allow-origin", "*");
|
|
if (req.method !== "POST") {
|
|
res.writeHead(405, { "content-type": "application/json" });
|
|
return res.end(JSON.stringify({ error: "POST only" }));
|
|
}
|
|
const CT_TO_EXT = {
|
|
"image/svg+xml": "svg", "image/png": "png", "image/jpeg": "jpg",
|
|
"image/gif": "gif", "image/webp": "webp", "image/x-icon": "ico",
|
|
};
|
|
const ct = (req.headers["content-type"] || "").split(";")[0].trim().toLowerCase();
|
|
const ext = CT_TO_EXT[ct];
|
|
if (!ext) {
|
|
res.writeHead(415, { "content-type": "application/json" });
|
|
return res.end(JSON.stringify({ error: "unsupported content-type; allowed: " + Object.keys(CT_TO_EXT).join(", ") }));
|
|
}
|
|
const chunks = [];
|
|
let total = 0;
|
|
const LIMIT = 128 * 1024;
|
|
let aborted = false;
|
|
req.on("data", (c) => {
|
|
total += c.length;
|
|
if (total > LIMIT) {
|
|
aborted = true;
|
|
req.destroy();
|
|
try { res.writeHead(413, { "content-type": "application/json" }); res.end(JSON.stringify({ error: "body too large (>128 KB)" })); } catch {}
|
|
return;
|
|
}
|
|
chunks.push(c);
|
|
});
|
|
req.on("end", async () => {
|
|
if (aborted) return;
|
|
try {
|
|
const bytes = Buffer.concat(chunks);
|
|
const { createHash } = await import("node:crypto");
|
|
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
const key = `bns/potidaea/icons/${sha256}.${ext}`;
|
|
const putUrl = `${S3_ENDPOINT}/${key}`;
|
|
const r = await aws.fetch(putUrl, { method: "PUT", body: bytes, headers: { "content-type": ct } });
|
|
if (!r.ok) {
|
|
const detail = await r.text().catch(() => "");
|
|
res.writeHead(502, { "content-type": "application/json" });
|
|
return res.end(JSON.stringify({ error: "Sia PUT failed", status: r.status, detail: detail.slice(0, 200) }));
|
|
}
|
|
const publicUrl = `https://navigate.st/bns/potidaea.asm/icons/${sha256}.${ext}`;
|
|
res.writeHead(200, { "content-type": "application/json" });
|
|
res.end(JSON.stringify({ sha256, ext, path: `s3://${key}`, url: publicUrl, bytes: bytes.length }));
|
|
} catch (e) {
|
|
res.writeHead(500, { "content-type": "application/json" });
|
|
res.end(JSON.stringify({ error: String(e?.message || e) }));
|
|
}
|
|
});
|
|
req.on("error", () => { /* swallow */ });
|
|
return;
|
|
}
|
|
|
|
// --- registry API: advertised TLDs (mirrors on-chain `tlds.bch` "tlds" record) ---
|
|
// GET /api/tlds -> { source, height, updated, tlds: [{ tld, registered, in_advertised }], advertised: [...] }
|
|
// The list a name-selling site scans to know which suffixes it may sell.
|
|
// `advertised` is exactly what the chain says (space-split); `tlds` is the
|
|
// observed union of every TLD that appears on any registered name plus every
|
|
// TLD in the advertised list — so a shop sees "sm" (advertised) even if no
|
|
// .sm has been registered yet, and "silentmode" (registered) even if it
|
|
// isn't advertised.
|
|
if (req.url === "/api/tlds" || req.url.startsWith("/api/tlds?")) {
|
|
res.setHeader("access-control-allow-origin", "*");
|
|
res.setHeader("content-type", "application/json");
|
|
try {
|
|
const idx = await getIndex();
|
|
const observed = new Map(); // tld -> count of names
|
|
for (const key of idx.keys()) {
|
|
const dot = key.indexOf(".");
|
|
const tld = dot < 0 ? "bch" : key.slice(dot + 1);
|
|
observed.set(tld, (observed.get(tld) || 0) + 1);
|
|
}
|
|
const anchor = idx.get("tlds.bch");
|
|
const advertisedRaw = (anchor?.records?.tlds ?? "").trim();
|
|
const advertised = advertisedRaw ? advertisedRaw.split(/\s+/) : [];
|
|
const known = new Set([...advertised, ...observed.keys()]);
|
|
// include_hidden=1 returns every TLD (used by the admin panel);
|
|
// otherwise HIDDEN_TLDS are filtered from the public listing.
|
|
const url = new URL(req.url, "http://x");
|
|
const includeHidden = url.searchParams.get("include_hidden") === "1";
|
|
const tlds = [...known].sort()
|
|
.filter((tld) => includeHidden || !HIDDEN_TLDS.has(tld))
|
|
.map((tld) => ({
|
|
tld,
|
|
registered_count: observed.get(tld) || 0,
|
|
in_advertised: advertised.includes(tld),
|
|
hidden: HIDDEN_TLDS.has(tld) || undefined,
|
|
}));
|
|
res.writeHead(200);
|
|
res.end(JSON.stringify({
|
|
source: "tlds.bch",
|
|
height: anchor?.height ?? null,
|
|
updated_txid: anchor?.updatedTxid ?? anchor?.txid ?? null,
|
|
advertised,
|
|
hidden_count: HIDDEN_TLDS.size,
|
|
tlds,
|
|
}, null, 2));
|
|
} catch (e) {
|
|
res.writeHead(500); res.end(JSON.stringify({ error: e.message }));
|
|
}
|
|
return;
|
|
}
|
|
|
|
// --- operator: read/write the TLD visibility list ---
|
|
// GET /api/tld-visibility -> { hidden: [...] } (public)
|
|
// POST /api/tld-visibility -> { hidden: [...] } saves it (operator-gated)
|
|
// Body: {"hidden":["foo","bar"]} or {"tld":"foo","hide":true|false}
|
|
// Header: Authorization: Bearer <BNS_OPERATOR_TOKEN>
|
|
// The list is persisted to hidden-tlds.json next to the gateway script
|
|
// and used by /api/tlds?include_hidden=0 (default) to omit those TLDs
|
|
// from public name-search UIs. On-chain registrations under a hidden
|
|
// TLD keep resolving — this is a UX filter, not an enforcement gate.
|
|
if (req.url === "/api/tld-visibility") {
|
|
res.setHeader("access-control-allow-origin", "*");
|
|
res.setHeader("access-control-allow-headers", "authorization, content-type");
|
|
res.setHeader("access-control-allow-methods", "GET, POST, OPTIONS");
|
|
res.setHeader("content-type", "application/json");
|
|
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
|
|
if (req.method === "GET") {
|
|
res.writeHead(200);
|
|
res.end(JSON.stringify({ hidden: [...HIDDEN_TLDS].sort() }));
|
|
return;
|
|
}
|
|
if (req.method === "POST") {
|
|
if (!OPERATOR_TOKEN) {
|
|
res.writeHead(503);
|
|
res.end(JSON.stringify({ error: "gateway has no BNS_OPERATOR_TOKEN set; writes disabled" }));
|
|
return;
|
|
}
|
|
const auth = req.headers.authorization || "";
|
|
if (auth !== `Bearer ${OPERATOR_TOKEN}`) {
|
|
res.writeHead(401);
|
|
res.end(JSON.stringify({ error: "operator token required" }));
|
|
return;
|
|
}
|
|
try {
|
|
const chunks = [];
|
|
for await (const c of req) chunks.push(c);
|
|
const body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
|
|
const next = new Set(HIDDEN_TLDS);
|
|
if (Array.isArray(body.hidden)) {
|
|
next.clear();
|
|
for (const t of body.hidden) if (typeof t === "string" && /^[a-z0-9-]{1,16}$/.test(t)) next.add(t);
|
|
} else if (typeof body.tld === "string" && /^[a-z0-9-]{1,16}$/.test(body.tld)) {
|
|
if (body.hide) next.add(body.tld); else next.delete(body.tld);
|
|
} else {
|
|
res.writeHead(400);
|
|
res.end(JSON.stringify({ error: "expected {hidden:[...]} or {tld,hide}" }));
|
|
return;
|
|
}
|
|
HIDDEN_TLDS = next;
|
|
writeHiddenTlds(HIDDEN_TLDS);
|
|
res.writeHead(200);
|
|
res.end(JSON.stringify({ hidden: [...HIDDEN_TLDS].sort() }));
|
|
} catch (e) {
|
|
res.writeHead(400);
|
|
res.end(JSON.stringify({ error: e.message }));
|
|
}
|
|
return;
|
|
}
|
|
res.writeHead(405); res.end(); return;
|
|
}
|
|
|
|
// --- registry API: dump every registered name ---
|
|
// GET /api/registry -> { count, height_hint, names: [{ name, tld, records, category, txid, height, updated_txid }] }
|
|
// The bulk snapshot a name-selling site can cache to compute availability
|
|
// offline. Refreshed at most every 30s server-side (same cache as /api/*).
|
|
if (req.url === "/api/registry" || req.url.startsWith("/api/registry?")) {
|
|
res.setHeader("access-control-allow-origin", "*");
|
|
res.setHeader("content-type", "application/json");
|
|
try {
|
|
const idx = await getIndex();
|
|
const names = [];
|
|
let heightHint = 0;
|
|
for (const [key, entry] of idx) {
|
|
const dot = key.indexOf(".");
|
|
const displayName = dot < 0 ? key + ".bch" : key;
|
|
const tld = dot < 0 ? "bch" : key.slice(dot + 1);
|
|
if (entry?.height && entry.height > heightHint) heightHint = entry.height;
|
|
names.push({
|
|
name: displayName,
|
|
tld,
|
|
records: entry?.records ?? null,
|
|
category: entry?.category ?? null,
|
|
txid: entry?.txid ?? null,
|
|
height: entry?.height ?? null,
|
|
updated_txid: entry?.updatedTxid ?? null,
|
|
});
|
|
}
|
|
names.sort((a, b) => a.name.localeCompare(b.name));
|
|
res.writeHead(200);
|
|
res.end(JSON.stringify({ count: names.length, height_hint: heightHint, names }, null, 2));
|
|
} catch (e) {
|
|
res.writeHead(500); res.end(JSON.stringify({ error: e.message }));
|
|
}
|
|
return;
|
|
}
|
|
|
|
// GET /api/holdings/<scripthash> -> { scripthash, count, names: [...] }
|
|
// POST /api/holdings body: {"scripthashes":[…]} -> { count, names: [...] }
|
|
//
|
|
// Server-side wallet-holdings lookup for the sirius.x portal. bns-indexer
|
|
// only serves the beacon; arbitrary-address listunspent needs a full
|
|
// electrum, which many browsers cannot reach on ports 50004/50011/62002
|
|
// (firewalls, mobile carriers). The gateway can reach them, so it queries
|
|
// and returns the cross-joined result: the client POSTs a scripthash list
|
|
// and gets back the BCNR names each hash currently holds an NFT for.
|
|
//
|
|
// Takes scripthash (not cashaddr) to keep the gateway libauth-free; the
|
|
// client's wallet code already knows the scripthash for each of its
|
|
// watched addresses.
|
|
if (req.url === "/api/holdings" || req.url.startsWith("/api/holdings/") || req.url.startsWith("/api/holdings?")) {
|
|
res.setHeader("access-control-allow-origin", "*");
|
|
res.setHeader("content-type", "application/json");
|
|
let scripthashes = [];
|
|
if (req.method === "POST") {
|
|
try {
|
|
const chunks = [];
|
|
for await (const c of req) chunks.push(c);
|
|
const body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
|
|
scripthashes = Array.isArray(body.scripthashes) ? body.scripthashes : [];
|
|
} catch { res.writeHead(400); res.end(JSON.stringify({ error: "invalid JSON body" })); return; }
|
|
} else {
|
|
const one = req.url.slice("/api/holdings/".length).split("?")[0];
|
|
if (one) scripthashes = [one];
|
|
}
|
|
scripthashes = scripthashes.filter((s) => typeof s === "string" && /^[0-9a-f]{64}$/i.test(s));
|
|
if (!scripthashes.length) {
|
|
res.writeHead(400);
|
|
res.end(JSON.stringify({ error: "no valid scripthashes (need 64-char hex, POST as {scripthashes:[…]} or GET /api/holdings/<hex>)" }));
|
|
return;
|
|
}
|
|
if (scripthashes.length > 64) {
|
|
res.writeHead(400); res.end(JSON.stringify({ error: "at most 64 scripthashes per request" })); return;
|
|
}
|
|
// Skip beacon-only indexers (silentmode.st/electrum on 443, our own
|
|
// bns-indexer on :50011) — they don't implement listunspent.
|
|
const BNS_INDEXER_URLS = /silentmode\.st\/electrum|coinspectrum\.duckdns\.org:50011/;
|
|
const candidates = CHIPNET_ELECTRUM
|
|
.map((s) => (typeof s === "string" ? { url: s } : s))
|
|
.filter((s) => !BNS_INDEXER_URLS.test(s.url));
|
|
let el, lastErr;
|
|
for (const s of candidates) {
|
|
try { el = await connectAny([s], WebSocket); break; }
|
|
catch (e) { lastErr = e; }
|
|
}
|
|
if (!el) {
|
|
res.writeHead(503);
|
|
res.end(JSON.stringify({ error: "no full electrum reachable", detail: String(lastErr?.message || lastErr) }));
|
|
return;
|
|
}
|
|
try {
|
|
const utxoLists = await Promise.all(scripthashes.map((sh) =>
|
|
el.call("blockchain.scripthash.listunspent", [sh, "include_tokens"]).catch(() => [])
|
|
));
|
|
const categories = new Set();
|
|
for (const list of utxoLists) {
|
|
for (const u of list) if (u.token_data?.nft) categories.add(u.token_data.category);
|
|
}
|
|
const idx = await getIndex();
|
|
const names = [];
|
|
for (const [key, entry] of idx) {
|
|
if (!categories.has(entry.category)) continue;
|
|
const dot = key.indexOf(".");
|
|
const displayName = dot < 0 ? key + ".bch" : key;
|
|
const tld = dot < 0 ? "bch" : key.slice(dot + 1);
|
|
names.push({
|
|
name: displayName,
|
|
tld,
|
|
category: entry.category,
|
|
records: entry.records ?? null,
|
|
txid: entry.txid ?? null,
|
|
height: entry.height ?? null,
|
|
updated_txid: entry.updatedTxid ?? null,
|
|
});
|
|
}
|
|
names.sort((a, b) => a.name.localeCompare(b.name));
|
|
const body = req.method === "POST"
|
|
? { count: names.length, scripthashes_queried: scripthashes.length, names }
|
|
: { scripthash: scripthashes[0], count: names.length, names };
|
|
res.writeHead(200);
|
|
res.end(JSON.stringify(body, null, 2));
|
|
} catch (e) {
|
|
res.writeHead(500);
|
|
res.end(JSON.stringify({ error: String(e.message || e) }));
|
|
} finally {
|
|
try { el.close(); } catch {}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// --- registrar API: availability of a name ---
|
|
// GET /api/name/<name> -> { name, tld, registered, records? }
|
|
if (req.url.startsWith("/api/name/")) {
|
|
res.setHeader("access-control-allow-origin", "*");
|
|
res.setHeader("content-type", "application/json");
|
|
const raw = decodeURIComponent(req.url.slice("/api/name/".length).split("?")[0]);
|
|
try {
|
|
const key = normalizeName(raw); // "<label>.<tld>"
|
|
const tld = key.split(".").pop();
|
|
const entry = (await getIndex()).get(key);
|
|
res.writeHead(200);
|
|
res.end(JSON.stringify({
|
|
name: key, tld, registered: !!entry,
|
|
records: entry?.records ?? null, category: entry?.category ?? null,
|
|
}));
|
|
} catch (e) {
|
|
res.writeHead(400); res.end(JSON.stringify({ error: e.message }));
|
|
}
|
|
return;
|
|
}
|
|
|
|
// --- registrar API: all names sharing a label across every TLD ---
|
|
// GET /api/labels/<label> -> { label, matches: [{ name, tld, records, category }] }
|
|
// Powers navigate.st bare-label search: type "poutakidis" and get both
|
|
// poutakidis.bch (if registered) and poutakidis.de rather than pinning to .bch.
|
|
if (req.url.startsWith("/api/labels/")) {
|
|
res.setHeader("access-control-allow-origin", "*");
|
|
res.setHeader("content-type", "application/json");
|
|
const raw = decodeURIComponent(req.url.slice("/api/labels/".length).split("?")[0]).toLowerCase();
|
|
if (!/^[a-z0-9-]{1,32}$/.test(raw)) {
|
|
res.writeHead(400);
|
|
res.end(JSON.stringify({ error: "invalid label" }));
|
|
return;
|
|
}
|
|
try {
|
|
const idx = await getIndex();
|
|
const matches = [];
|
|
for (const [key, entry] of idx) {
|
|
// Index keys: bare "<label>" for .bch, "<label>.<tld>" otherwise
|
|
// (see normalizeName in resolver-web.js).
|
|
const dot = key.indexOf(".");
|
|
if (dot < 0) {
|
|
if (key === raw) matches.push({
|
|
name: key + ".bch", tld: "bch",
|
|
records: entry?.records ?? null, category: entry?.category ?? null,
|
|
});
|
|
} else if (key.slice(0, dot) === raw) {
|
|
matches.push({
|
|
name: key, tld: key.slice(dot + 1),
|
|
records: entry?.records ?? null, category: entry?.category ?? null,
|
|
});
|
|
}
|
|
}
|
|
matches.sort((a, b) => a.tld.localeCompare(b.tld));
|
|
res.writeHead(200);
|
|
res.end(JSON.stringify({ label: raw, matches }));
|
|
} catch (e) {
|
|
res.writeHead(500); res.end(JSON.stringify({ error: e.message }));
|
|
}
|
|
return;
|
|
}
|
|
|
|
// --- unified search: label + content + fuzzy fallback ---
|
|
// GET /api/search/<q> -> { query, count, results: [{ name, tld, matchType, score, snippet, dist?, records }] }
|
|
// Scores: label-exact (100) > label-substring (70) > h-record content (50) > fuzzy (40|25|10 for dist 1|2|3).
|
|
// Fuzzy is bounded Levenshtein against the label — catches typos like "hlelo" → "hello.bch"
|
|
// without polluting exact-match results (fuzzy hits only rank below every other match type).
|
|
// Content search covers on-chain inline HTML only; Sia/ip sites await Helios v1.
|
|
// Same cached index as /api/labels/ and /api/name/ — no extra fetch cost on the hot path.
|
|
if (req.url.startsWith("/api/search/")) {
|
|
res.setHeader("access-control-allow-origin", "*");
|
|
res.setHeader("content-type", "application/json");
|
|
const q = decodeURIComponent(req.url.slice("/api/search/".length).split("?")[0]).toLowerCase().trim();
|
|
if (!q || q.length < 2 || q.length > 64) {
|
|
res.writeHead(400);
|
|
res.end(JSON.stringify({ error: "query must be 2-64 chars" }));
|
|
return;
|
|
}
|
|
// Bounded Levenshtein: early-exits once the running minimum row-value exceeds `max`.
|
|
// Returns `max + 1` for "too far to matter" — the caller ignores anything > max.
|
|
const boundedLev = (a, b, max) => {
|
|
if (Math.abs(a.length - b.length) > max) return max + 1;
|
|
if (a === b) return 0;
|
|
// Ensure a is the shorter for less memory.
|
|
if (a.length > b.length) { const t = a; a = b; b = t; }
|
|
let prev = new Array(a.length + 1);
|
|
for (let i = 0; i <= a.length; i++) prev[i] = i;
|
|
for (let j = 1; j <= b.length; j++) {
|
|
const curr = new Array(a.length + 1);
|
|
curr[0] = j;
|
|
let rowMin = curr[0];
|
|
const bj = b.charCodeAt(j - 1);
|
|
for (let i = 1; i <= a.length; i++) {
|
|
const cost = a.charCodeAt(i - 1) === bj ? 0 : 1;
|
|
curr[i] = Math.min(prev[i] + 1, curr[i - 1] + 1, prev[i - 1] + cost);
|
|
if (curr[i] < rowMin) rowMin = curr[i];
|
|
}
|
|
if (rowMin > max) return max + 1; // no path can recover below the threshold
|
|
prev = curr;
|
|
}
|
|
return prev[a.length];
|
|
};
|
|
// Typo budget scales with query length so short queries don't match everything.
|
|
const maxDist = q.length <= 3 ? 1 : q.length <= 6 ? 2 : 3;
|
|
try {
|
|
const idx = await getIndex();
|
|
const results = [];
|
|
for (const [key, entry] of idx) {
|
|
const dot = key.indexOf(".");
|
|
const label = dot < 0 ? key : key.slice(0, dot);
|
|
const tld = dot < 0 ? "bch" : key.slice(dot + 1);
|
|
const displayName = dot < 0 ? key + ".bch" : key;
|
|
const records = entry?.records ?? {};
|
|
// Names on chain but hidden from free-text search — direct lookup
|
|
// (/api/name/, /bns/…) still works so demos stay reachable, they
|
|
// just don't clutter the suggestion list. syskypo.de is a mirror
|
|
// of poutakidis-content, kept live but redundant in search.
|
|
if (HIDDEN_FROM_SEARCH.has(displayName)) continue;
|
|
|
|
let matchType = null, score = 0, snippet = null, dist;
|
|
if (label === q) { matchType = "label-exact"; score = 100; }
|
|
else if (label.includes(q)) { matchType = "label-substring"; score = 70; }
|
|
|
|
// Content search over inline HTML (h record). Strip tags first so a
|
|
// query like "poutakidis" doesn't accidentally match "class=poutakidis".
|
|
if (typeof records.h === "string" && records.h) {
|
|
const text = records.h.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
const i = text.toLowerCase().indexOf(q);
|
|
if (i >= 0) {
|
|
if (!matchType) { matchType = "content"; score = 50; }
|
|
const start = Math.max(0, i - 40);
|
|
const end = Math.min(text.length, i + q.length + 60);
|
|
snippet = (start > 0 ? "…" : "") + text.slice(start, end) + (end < text.length ? "…" : "");
|
|
}
|
|
}
|
|
|
|
// Fuzzy fallback — only when nothing above matched, only against the label.
|
|
// Score decays sharply so a real substring match always outranks a 1-edit near-miss.
|
|
if (!matchType) {
|
|
const d = boundedLev(q, label, maxDist);
|
|
if (d <= maxDist) {
|
|
matchType = "fuzzy";
|
|
dist = d;
|
|
score = d === 1 ? 40 : d === 2 ? 25 : 10;
|
|
}
|
|
}
|
|
|
|
if (matchType) {
|
|
const row = { name: displayName, tld, matchType, score, snippet,
|
|
records: entry?.records ?? null, category: entry?.category ?? null };
|
|
if (dist !== undefined) row.dist = dist;
|
|
results.push(row);
|
|
}
|
|
}
|
|
results.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
|
|
const capped = results.slice(0, 50);
|
|
res.writeHead(200);
|
|
res.end(JSON.stringify({ query: q, count: capped.length, truncated: results.length > 50, results: capped }));
|
|
} catch (e) {
|
|
res.writeHead(500); res.end(JSON.stringify({ error: e.message }));
|
|
}
|
|
return;
|
|
}
|
|
|
|
// --- subdomain form: <flattened>.bch.<SUB_BASE> ---
|
|
if (hostHeader.endsWith("." + SUB_BASE)) {
|
|
const inner = hostHeader.slice(0, -("." + SUB_BASE).length); // "<flattened>.bch" or "<flattened>"
|
|
const flat = inner.endsWith(".bch") ? inner.slice(0, -4) : inner;
|
|
if (flat) {
|
|
const name = flat.replace(/-/g, ".") + ".bch"; // dashes back to dots
|
|
return serve(res, name, req.url || "/", null); // own origin: no <base>
|
|
}
|
|
}
|
|
// bare apex of SUB_BASE (e.g. bch.navigate.st) -> home
|
|
if (hostHeader === SUB_BASE) { res.writeHead(200, { "content-type": "text/html" }); return res.end(home()); }
|
|
|
|
// --- path form: /bns/<name>.bch/... ---
|
|
let p = req.url;
|
|
if (p === MOUNT || p === MOUNT + "/") { res.writeHead(200, { "content-type": "text/html" }); return res.end(home()); }
|
|
if (!p.startsWith(MOUNT + "/")) { res.writeHead(404); return res.end("bns gateway"); }
|
|
p = p.slice(MOUNT.length + 1);
|
|
const m = p.match(/^([a-z0-9-]+(?:\.[a-z0-9-]+)+)(\/.*)?$/i); // <name>.<tld>, any TLD
|
|
if (!m) { res.writeHead(400, { "content-type": "text/html" }); return res.end(`usage: ${MOUNT}/<name>.<tld>/`); }
|
|
const name = m[1].toLowerCase();
|
|
const rest = m[2] || "/";
|
|
return serve(res, name, rest, `${MOUNT}/${name}/`);
|
|
} catch (e) { res.writeHead(500); res.end("gateway error: " + e.message); }
|
|
});
|
|
server.listen(PORT, "127.0.0.1", () => console.log(`bns public gateway on 127.0.0.1:${PORT} (path ${MOUNT}, subdomain *.${SUB_BASE})`));
|
|
|
|
function home() {
|
|
return `<!doctype html><meta charset=utf-8><title>BNS Gateway</title>
|
|
<body style="font:16px system-ui;max-width:640px;margin:3rem auto;padding:0 1rem;color:#e7eaf1;background:#0f1420">
|
|
<h1 style="color:#d6ff3d">⛓ BNS Gateway</h1>
|
|
<p>View Bitcoin Cash <b>.bch</b> names in any browser — no install.</p>
|
|
<ul>
|
|
<li><a href="/bns/hello.bch">/bns/hello.bch</a> · or <a href="https://hello.${SUB_BASE}/">hello.${SUB_BASE}</a></li>
|
|
<li><a href="/bns/coinspectrum.deviant.bch">/bns/coinspectrum.deviant.bch</a></li>
|
|
</ul>
|
|
<p style="color:#8b98a9;font-size:13px">This gateway resolves names for you, so it can see which .bch
|
|
sites you request (it keeps no logs — you're trusting that). For trustless resolution install the local
|
|
resolver/extension; for IP privacy use Tor. By Silent Mode (a Deviant project).</p></body>`;
|
|
}
|