argus/src/gateway/public-gateway.mjs
Local Dev ff76e4cf94 sirius.x portal: /api/holdings endpoint + favicon paths + roadmap link
gateway/public-gateway.mjs:
  * GET /api/holdings/<scripthash> and POST /api/holdings {scripthashes:[…]}
    — server-side wallet-holdings lookup for the portal. Bns-indexer is
    beacon-only so it can't answer listunspent for arbitrary addresses; the
    gateway does the electrum roundtrip and cross-joins with the cached BCNR
    index. Client posts scripthashes (no libauth needed on the server) and
    gets back the names owned.
  * elConnect now falls through to whole-buffer JSON.parse when a Fulcrum
    response lacks a trailing newline — chipnet.bch.ninja does this and
    otherwise every elCall to it times out. Same tolerance pattern as
    bns-indexer.test.mjs's tiny electrum client.
  * verifyElectrum picks a full electrum with a 3s server.version probe so a
    hung server fails over to the next in seconds instead of stalling 20.
  * Skips silentmode.st/electrum and coinspectrum.duckdns.org:50011 for
    /api/holdings — both are bns-indexer routes that don't do listunspent.

web/register-entry.js: export addressToScripthash so the portal can derive
the scripthash for each of its wallet's watched addresses before POSTing.
Browser bundle rebuilt.

site-sirius-x pages:
  * favicon + logo hrefs made relative (./assets/… on index/portal,
    ../assets/… on docs/theseus). Absolute /sirius-x/… broke on the sirius.x
    BCNR route where the site is served from root instead of under /sirius-x/.
  * Roadmap nav link added back to portal, docs, theseus — the landing has an
    anchor to a section on itself, so subpages now link to it explicitly.
  * portal.html: loadNames rewritten to POST scripthashes to /api/holdings
    instead of doing client-side electrum. Works on browsers whose networks
    block chipnet electrum ports; same-origin HTTPS on 443.
  * portal.html: bundle import ?v= bumped so Chrome's in-memory ES module
    map picks up the new bundle instead of a stale cached copy.
2026-08-31 05:40:47 +02:00

723 lines
35 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, CHIPNET_ELECTRUM } from "../lib/resolver-web.js";
import { pickRecord, isSubdomainOf } from "../lib/record-picker.js";
import { buildTldSnapshot } from "../lib/tld-index.js";
// TLD-certificate beacon scripthash (Decentralized.DNS/DESIGN-tld-registry.md §3).
// Constant; keep in step with Argus/src/config.js:TLD_BEACON and the indexer's
// BNS_TLD_BEACON_SCRIPTHASH default.
const TLD_BEACON_SCRIPTHASH = "c85db10e106606273422b2733b2a6fb8b35a4d0e7dbfb20cf59bdb0833c008b8";
// Legacy TLDs to omit from the public `/api/tlds` listing by default. A legacy
// entry here is one that a name was accidentally minted under (typo, test
// artefact) that we do not want a name shop advertising as a browseable
// namespace. They still resolve, and pass `?hidden=1` to see them.
// `silentmode` — one-off (`releases.silentmode`) meant to be .st or .bch.
const HIDDEN_LEGACY_TLDS = new Set(["silentmode"]);
// 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;
}
// Minimal newline-delimited JSON-RPC electrum client over a single WS. Same
// framing as resolver-web.js's internal Electrum class and bns-indexer.test.mjs
// — kept inline here so the gateway can query an arbitrary scripthash (the
// resolver-web helpers only know the name beacon) without exporting internals.
async function elCall(el, method, params = []) {
return new Promise((resolve, reject) => {
const id = ++el.id;
el.pending.set(id, { resolve, reject });
el.ws.send(JSON.stringify({ id, method, params }) + "\n");
setTimeout(() => {
if (el.pending.has(id)) { el.pending.delete(id); reject(new Error(`electrum timeout: ${method}`)); }
}, 20000);
});
}
function elConnect(url) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
const el = { ws, id: 0, pending: new Map(), buf: "" };
ws.on("open", () => resolve(el));
ws.on("error", (e) => reject(e));
ws.on("message", (data) => {
el.buf += data.toString();
// Newline-delimited messages first — Fulcrum's normal framing.
let nl;
while ((nl = el.buf.indexOf("\n")) >= 0) {
const line = el.buf.slice(0, nl).trim();
el.buf = el.buf.slice(nl + 1);
if (!line) continue;
try {
const m = JSON.parse(line);
const p = el.pending.get(m.id);
if (p) { el.pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); }
} catch { /* ignore stray frame */ }
}
// Some Fulcrum builds (and chipnet.bch.ninja specifically) omit the
// trailing newline. If what's left in the buffer parses as a complete
// JSON message, settle it. Same pattern as bns-indexer.test.mjs's
// "some servers omit the trailing newline" branch.
const rest = el.buf.trim();
if (rest) {
try {
const m = JSON.parse(rest);
const p = el.pending.get(m.id);
if (p) {
el.pending.delete(m.id);
el.buf = "";
m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result);
}
} catch { /* still partial — wait for more bytes */ }
}
});
});
}
// TLD snapshot cache (30s), same TTL as the name index.
let _tld = { at: 0, snapshot: null };
async function getTldSnapshot() {
if (_tld.snapshot && Date.now() - _tld.at < 30000) return _tld.snapshot;
// Prefer the local BCHN-backed indexer (127.0.0.1:50010) — it indexes both
// the name beacon and the TLD beacon on one WS since bns-indexer.js is
// multi-beacon. Falls back to the public pool if the local endpoint is
// unreachable (a dev running the gateway without a local indexer).
const candidates = [
process.env.BNS_ELECTRUM_LOCAL || "ws://127.0.0.1:50010",
...CHIPNET_ELECTRUM.map((s) => s.url),
];
let el, lastErr;
for (const url of candidates) {
try { el = await elConnect(url); break; } catch (e) { lastErr = e; }
}
if (!el) throw lastErr ?? new Error("no electrum reachable for TLD snapshot");
try {
await elCall(el, "server.version", ["bns-gateway", "1.4.3"]);
const history = await elCall(el, "blockchain.scripthash.get_history", [TLD_BEACON_SCRIPTHASH]);
const txs = new Map();
for (const { tx_hash } of history) {
try { txs.set(tx_hash, await elCall(el, "blockchain.transaction.get", [tx_hash, true])); }
catch { /* unreadable tx — skip */ }
}
const snapshot = buildTldSnapshot({ history, txs });
_tld = { at: Date.now(), snapshot };
return snapshot;
} finally { try { el.ws.close(); } catch {} }
}
const MOUNT = process.env.BNS_MOUNT || "/bns";
const PORT = Number(process.env.BNS_PORT || 8700);
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);
}
// 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);
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 "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);
}
// --- registry API: TLDs from the on-chain TLD-certificate beacon ---
// GET /api/tlds -> { source, root, count, legend, tlds: [{ tld, certificate, ... }] }
// The authoritative list a name-selling site scans to know which suffixes it
// may sell. Each TLD is a first-class on-chain certificate (TREG NFT + records)
// per Decentralized.DNS/DESIGN-tld-registry.md §3; this endpoint returns the
// snapshot buildTldSnapshot() produces so the response `root` is byte-identical
// between any honest producer looking at the same chain state.
//
// Every entry carries `certificate` — true for TLDs registered on TLD_BEACON
// (the covenanted, sellable path), false for "legacy" TLDs: TLDs that had
// names REG'd under them before the TLD-certificate model landed (the
// list-in-one-NFT `tlds.bch` era). Legacy entries resolve today but have no
// owner NFT, no policy record, and are not sellable through the registrar
// flow. They stay visible so a shop can WARN a user browsing existing names
// ("this is legacy, will be invalidated on the mainnet migration") without
// offering them for sale. See the `legend` field in the response body.
//
// `registered_count` overlays the NAME index on top: how many names are
// registered under each TLD right now. A certified TLD with 0 names is a
// fresh registry ready to sell.
//
// `hidden` (query flag) — legacy TLDs on the HIDDEN_LEGACY_TLDS list are
// suppressed by default. Pass `?hidden=1` to include them (introspection).
if (req.url === "/api/tlds" || req.url.startsWith("/api/tlds?")) {
res.setHeader("access-control-allow-origin", "*");
res.setHeader("content-type", "application/json");
try {
const wantHidden = /(?:^|[?&])hidden=1(?:&|$)/.test(req.url);
const [snap, idx] = await Promise.all([getTldSnapshot(), 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 certified = new Set(snap.tlds.map((t) => t.tld));
const tlds = snap.tlds.map((t) => ({
tld: t.tld,
certificate: true,
category: t.category,
reg_height: t.regHeight,
reg_txid: t.regTxid,
updated_txid: t.updatedTxid,
records: t.records,
owner: t.owner,
registered_count: observed.get(t.tld) || 0,
}));
// Legacy TLDs that have names under them but no certificate on TLD_BEACON.
// Surfaced so name-shops can warn "this TLD is not covenant-registered".
const legacyAll = [...observed.entries()]
.filter(([tld]) => !certified.has(tld))
.map(([tld, count]) => ({ tld, certificate: false, registered_count: count }));
const legacy = wantHidden
? legacyAll
: legacyAll.filter((e) => !HIDDEN_LEGACY_TLDS.has(e.tld));
const all = [...tlds, ...legacy].sort((a, b) => a.tld.localeCompare(b.tld));
res.writeHead(200);
res.end(JSON.stringify({
source: "TLD_BEACON",
root: snap.root,
count: all.length,
hidden_legacy: wantHidden ? [] : [...HIDDEN_LEGACY_TLDS].filter((t) => observed.has(t)),
legend: {
certificate_true:
"TLD has an on-chain TREG certificate (per-TLD NFT + records on TLD_BEACON, " +
"Decentralized.DNS/DESIGN-tld-registry.md §3). Sellable through the covenanted " +
"registrar path; the owner field is the operator that can TUPD its policy.",
certificate_false:
"'Legacy' — one or more names were REG'd under this TLD before per-TLD certificates " +
"existed. Still resolves via the name index, but has no owner NFT, no policy record, " +
"and cannot be sold through the covenanted flow. On the mainnet migration these names " +
"will need re-registration under a certified TLD (see design doc §2 item 2).",
},
tlds: all,
}, null, 2));
} catch (e) {
res.writeHead(500); res.end(JSON.stringify({ error: e.message }));
}
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 (and any other
// BCNR-aware client). Bns-indexer only serves the beacon; arbitrary-address
// listunspent needs a full electrum, which many browsers cannot reach on
// ports 50004/50011/62002 (corporate firewalls, mobile carriers). The
// gateway CAN reach them, so it queries and returns the cross-joined
// result — client just POSTs a scripthash list and gets back the names.
//
// 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");
// Collect scripthashes: URL path for GET, JSON body for POST.
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 (e) { 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;
}
// Connect to any full electrum. bns-indexer only supports get_history +
// transaction.get — no listunspent for arbitrary scripthashes. Skip
// every URL that resolves to our own bns-indexer (silentmode.st/electrum
// on 443, coinspectrum.duckdns.org:50011 on the direct port).
const BNS_INDEXER_URLS = /silentmode\.st\/electrum|coinspectrum\.duckdns\.org:50011/;
const candidates = CHIPNET_ELECTRUM.map((s) => s.url).filter((u) => !BNS_INDEXER_URLS.test(u));
// Connect + verify with a short timeout, so a hung server (an open TCP
// that never speaks JSON-RPC back) fails over to the next candidate in
// seconds instead of stalling the request for 20.
const verifyElectrum = async (url) => {
const c = await elConnect(url);
const versionPromise = new Promise((resolve, reject) => {
const id = ++c.id;
c.pending.set(id, { resolve, reject });
c.ws.send(JSON.stringify({ id, method: "server.version", params: ["bns-gateway", "1.4.3"] }) + "\n");
setTimeout(() => {
if (c.pending.has(id)) { c.pending.delete(id); reject(new Error("server.version timeout")); }
}, 3000);
});
try { await versionPromise; return c; }
catch (e) { try { c.ws.close(); } catch {} throw e; }
};
let el, lastErr, elUrl;
for (const url of candidates) {
try { el = await verifyElectrum(url); elUrl = url; console.log("api/holdings: verified", url); break; }
catch (e) { lastErr = e; console.log("api/holdings: skip", url, "-", e.message); }
}
if (!el) { res.writeHead(503); res.end(JSON.stringify({ error: "no full electrum reachable", detail: String(lastErr?.message || lastErr) })); return; }
try {
// Fan out listunspent in parallel. Chipnet is small; 64 concurrent
// listunspent calls on one WS is well within a public server's budget.
const utxoLists = await Promise.all(scripthashes.map((sh) =>
elCall(el, "blockchain.scripthash.listunspent", [sh, "include_tokens"]).catch(() => [])
));
// Collect NFT categories held by any of the scripthashes.
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);
}
// Cross-reference with the cached BCNR index.
const idx = await getIndex();
const names = [];
for (const entry of idx.values()) {
if (categories.has(entry.category)) {
const dot = entry.name.indexOf(".");
const displayName = dot < 0 ? entry.name + ".bch" : entry.name;
const tld = dot < 0 ? "bch" : entry.name.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.ws.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 ---
// GET /api/search/<q> -> { query, count, results: [{ name, tld, matchType, score, snippet, records }] }
// Scores: label-exact (100) > label-substring (70) > h-record content (50).
// 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;
}
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 ?? {};
let matchType = null, score = 0, snippet = null;
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 ? "…" : "");
}
}
if (matchType) {
results.push({ name: displayName, tld, matchType, score, snippet,
records: entry?.records ?? null, category: entry?.category ?? null });
}
}
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>`;
}