argus/src/gateway/public-gateway.mjs
Local Dev bdb4c6235d fix(studio): open the page the name actually serves, not an empty folder
Sirius Studio only looked in its own folder, bns/<name>/. Every site
published the conventional way lives at bns/<label>/ (the s3 record),
or inline in the h record, so the editor opened those names as "new
site" with a template picker instead of the current build.

The gateway's site read gains a read-only ?src=live view that follows
the on-chain record: list and fetch from whatever bucket s3 points at,
or serve the h HTML as index.html. Writes are unchanged and still
confined to bns/<name>/.

The editor now starts from the live page when there is no draft:
relative image and link URLs are resolved against the public site URL,
stylesheets are inlined (cross-origin ones such as font CDNs are kept
as links and re-emitted on export), scripts dropped. When a draft
exists but the name serves something else, or index.html on Sia is
newer than the draft, a notice says so with a one-click import. An
"Import live site" button is always available. Verified with
aloevera.test, which is hosted at bns/aloevera/.
2026-09-20 01:22:21 +02:00

1612 lines
89 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 { createHash } from "node:crypto";
// libauth is present so the next drop's signature-verify path is a
// contained edit (no dep install between now and then). Only utf8ToBin
// and sha256 are used today; the rest are named here so the imports
// are visible from the top of the file.
import {
utf8ToBin, base64ToBin, sha256, ripemd160,
secp256k1, encodeCashAddress, cashAddressToLockingBytecode, hexToBin, binToHex,
} from "@bitauth/libauth";
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, TLD_BEACON_SCRIPTHASH } from "./resolver-web.js";
import { pickRecord, isSubdomainOf } from "./record-picker.js";
import { buildTldSnapshot } from "./tld-index.js";
import { verifyListing } from "./market-tx.js";
// TLD-beacon snapshot cache. Rebuilds by walking TLD_BEACON via electrum,
// verbose-fetching every tx, and passing history+txs to buildTldSnapshot.
// This is the AUTHORITATIVE source for the per-TLD-NFT registry — the old
// `tlds.bch` root NFT is legacy and no longer authoritative (see
// Decentralized.DNS/DESIGN-tld-registry.md §9). Cached 30s.
let _tldSnap = { at: 0, snap: null };
async function getTldSnapshot() {
if (_tldSnap.snap && Date.now() - _tldSnap.at < 30000) return _tldSnap.snap;
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;
for (const s of candidates) {
try { el = await connectAny([s], WebSocket); break; } catch { /* try next */ }
}
if (!el) throw new Error("no full electrum reachable");
try {
const history = typeof el.unionHistory === "function"
? (await el.unionHistory(TLD_BEACON_SCRIPTHASH)).history
: await el.call("blockchain.scripthash.get_history", [TLD_BEACON_SCRIPTHASH]);
const txs = new Map();
for (const { tx_hash } of history) {
try { txs.set(tx_hash, await el.call("blockchain.transaction.get", [tx_hash, true])); }
catch { /* unreadable — skip */ }
}
const snap = buildTldSnapshot({ history, txs });
_tldSnap = { at: Date.now(), snap };
return snap;
} finally { try { el.close?.(); } catch {} }
}
// 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();
// ---------- marketplace + co-sign storage and helpers ----------
const MARKET_FILE = process.env.BNS_MARKET_FILE || _join(_dirname(fileURLToPath(import.meta.url)), "market.json");
const COSIGN_FILE = process.env.BNS_COSIGN_FILE || _join(_dirname(fileURLToPath(import.meta.url)), "cosign.json");
function readJsonList(file) { try { if (!_existsSync(file)) return []; const j = JSON.parse(_readFile(file, "utf8")); return Array.isArray(j) ? j : []; } catch { return []; } }
const readMarket = () => readJsonList(MARKET_FILE);
const writeMarket = (list) => _writeFile(MARKET_FILE, JSON.stringify(list, null, 2) + "\n", "utf8");
const readCosign = () => readJsonList(COSIGN_FILE);
const writeCosign = (list) => _writeFile(COSIGN_FILE, JSON.stringify(list, null, 2) + "\n", "utf8");
function lockingHexOf(cashaddr) {
const r = cashAddressToLockingBytecode(cashaddr);
if (typeof r === "string") throw new Error(`bad address ${cashaddr}: ${r}`);
return binToHex(r.bytecode);
}
const scripthashOfLock = (lockHex) => binToHex(sha256.hash(hexToBin(lockHex)).slice().reverse());
// Recover the signer of a BCH message signature over sha256(msg) and return
// it as a CashAddress with the same prefix as `likeAddress`.
function recoverSigner(sigB64, msg, likeAddress) {
try {
const sigBytes = base64ToBin(sigB64);
if (sigBytes.length !== 65) return null;
const digest = sha256.hash(utf8ToBin(msg));
const pub = secp256k1.recoverPublicKeyCompressed(sigBytes.slice(1), (sigBytes[0] - 27) & 3, digest);
if (typeof pub === "string") return null;
const prefix = String(likeAddress || "bchtest:").split(":")[0] || "bchtest";
const enc = encodeCashAddress({ prefix, type: "p2pkh", payload: ripemd160.hash(sha256.hash(pub)) });
return typeof enc === "string" ? enc : (enc?.address ?? null);
} catch { return null; }
}
// One full-electrum call (beacon-only indexers don't serve listunspent).
async function withFullElectrum(fn) {
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 = null, lastErr = null;
for (const s of candidates) { try { el = await connectAny([s], WebSocket); break; } catch (e) { lastErr = e; } }
if (!el) throw new Error("no full electrum reachable: " + String(lastErr?.message || lastErr));
try { return await fn(el); } finally { try { el.close(); } catch {} }
}
async function listUnspentForLock(lockHex) {
return withFullElectrum((el) => el.call("blockchain.scripthash.listunspent", [scripthashOfLock(lockHex), "include_tokens"]));
}
async function outpointUnspent(lockHex, txid, vout) {
const list = await listUnspentForLock(lockHex).catch(() => null);
if (!list) return true; // electrum blip: don't reject; the buyer's broadcast is the final check
return list.some((u) => u.tx_hash === txid && u.tx_pos === vout);
}
async function findTokenUtxo(lockHex, category, commitmentHex) {
const list = await listUnspentForLock(lockHex);
const u = list.find((x) => x.token_data?.category === category && x.token_data?.nft?.commitment === commitmentHex);
if (!u) return null;
return { txid: u.tx_hash, vout: u.tx_pos, satoshis: String(u.value), lockingBytecode: lockHex,
token: { category: u.token_data.category, amount: String(u.token_data.amount ?? 0), nft: { capability: u.token_data.nft.capability, commitment: u.token_data.nft.commitment } } };
}
// Listings whose certificate is still where the seller left it. Cached 30 s.
let _marketCheck = { at: 0, list: null };
async function activeListings() {
if (_marketCheck.list && Date.now() - _marketCheck.at < 30000) return _marketCheck.list;
const stored = readMarket();
if (!stored.length) { _marketCheck = { at: Date.now(), list: [] }; return []; }
const idx = await getIndex();
const keep = [];
const byLock = new Map();
for (const l of stored) {
const entry = idx.get(l.name);
if (!entry || entry.owner !== l.seller || entry.category !== l.category) continue; // sold or moved
const lock = lockingHexOf(l.seller);
if (!byLock.has(lock)) byLock.set(lock, await listUnspentForLock(lock).catch(() => null));
const list = byLock.get(lock);
if (list && !list.some((u) => u.tx_hash === l.source_output.txid && u.tx_pos === l.source_output.vout)) continue; // spent
keep.push({ ...l, records: entry.records ?? {} });
}
if (keep.length !== stored.length) writeMarket(keep);
_marketCheck = { at: Date.now(), list: keep };
return keep;
}
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",
});
// In-memory highest-seen seq per name, used by both the POST (records
// write) and GET (dns read) sides to reject rollback of a signed
// manifest. Loses state on gateway restart — that's acceptable in v1
// since Sia's newer-writer-wins covers the fresh state, and a restart
// window is a narrow attack surface. A future v2 can persist this to
// disk alongside the beacon snapshot.
const _dnsSeqCache = new Map();
// ---------- signed records manifest helpers ----------
// canonicalize(): recursively sort object keys, drop null/undefined so
// signable bytes are reproducible cross-implementation. Arrays keep order.
function canonicalize(x) {
if (x === null || x === undefined) return undefined;
if (Array.isArray(x)) return x.map(canonicalize).filter((v) => v !== undefined);
if (typeof x === "object") {
const out = {};
for (const k of Object.keys(x).sort()) {
const v = canonicalize(x[k]);
if (v !== undefined) out[k] = v;
}
return out;
}
return x;
}
function withoutKey(obj, k) {
const { [k]: _drop, ...rest } = obj;
return rest;
}
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;
let up = await aws.fetch(`${S3_ENDPOINT}/${key}`);
// Fallback chain for missing extensionless paths (order matters):
// 1) directory-index: try "<path>/index.html" — matches nginx
// try_files. This handles static sub-sites like /about that
// have their own index.html.
// 2) SPA fallback: serve the domain's ROOT index.html so
// client-side routers can pick up /katalogos, /anthemus, etc.
// Requests with a file extension (missing .png / .js / .json) are
// NOT rescued — those should never be routed by a client-side
// router and swallowing them would hide real bugs.
if (up.status === 404 && !/\.[a-z0-9]+$/i.test(reqPath) && reqPath !== "/") {
// Try directory-index first
if (!reqPath.endsWith("/")) {
const dirKey = (base.endsWith("/") ? base : base + "/") + reqPath.slice(1).replace(/\/+$/, '') + "/index.html";
const dir = await aws.fetch(`${S3_ENDPOINT}/${dirKey}`);
if (dir.ok) { up = dir; }
}
// Then SPA root fallback
if (up.status === 404) {
const fallbackKey = (base.endsWith("/") ? base : base + "/") + "index.html";
const fb = await aws.fetch(`${S3_ENDPOINT}/${fallbackKey}`);
if (fb.ok) { up = fb; }
}
}
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 {
// Authoritative TLD list: walk TLD_BEACON, each per-TLD NFT (TREG) is
// its own first-class registration. Legacy `tlds.bch` root record is
// still used only to derive `advertised` (which TLDs the operator
// wants shops to promote).
const [snap, idx] = await Promise.all([getTldSnapshot(), getIndex()]);
const observed = new Map(); // tld -> count of registered 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+/) : [];
// 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";
// Each row carries the owner's on-chain policy (`records` from the
// latest TREG/TUPD) so shops can price and gate without walking the
// beacon themselves:
// price_usd — flat USD price the owner set for every name under
// the TLD (records.price); null = tier pricing
// hidden — off the public listing. hidden_by tells why:
// "owner" (records.hidden=1, the owner switched the
// TLD off) or "operator" (HIDDEN_TLDS file).
// policy — records.policy (open | frozen | covenant), default open
const ownerHidden = (rec) => rec.hidden === 1 || rec.hidden === true || rec.hidden === "1";
const priceOf = (rec) => (typeof rec.price === "number" && Number.isFinite(rec.price) && rec.price >= 0 ? rec.price : null);
const all = [...snap.tlds]
.sort((a, b) => a.tld.localeCompare(b.tld))
.map((t) => {
const rec = t.records && typeof t.records === "object" ? t.records : {};
const byOwner = ownerHidden(rec);
const byOperator = HIDDEN_TLDS.has(t.tld);
return {
tld: t.tld,
registered_count: observed.get(t.tld) || 0,
in_advertised: advertised.includes(t.tld),
hidden: (byOwner || byOperator) || undefined,
hidden_by: byOwner ? "owner" : byOperator ? "operator" : undefined,
price_usd: priceOf(rec),
policy: typeof rec.policy === "string" ? rec.policy : "open",
records: rec,
owner: t.owner ?? null,
category: t.category,
reg_height: t.regHeight ?? null,
updated_txid: t.updatedTxid ?? null,
};
});
const tlds = all.filter((t) => includeHidden || !t.hidden);
res.writeHead(200);
res.end(JSON.stringify({
source: "TLD_BEACON",
height: snap.asOfHeight ?? null,
updated_txid: snap.asOfTxid ?? null,
root: snap.root ?? null,
advertised,
hidden_count: all.filter((t) => t.hidden).length,
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");
res.setHeader("access-control-allow-methods", "GET, POST, OPTIONS");
res.setHeader("access-control-allow-headers", "content-type");
res.setHeader("access-control-max-age", "86400");
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
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 {
// Single round-trip: fetch all UTXOs + the chain head in parallel.
// headers.subscribe returns the current tip on first call; keeping the
// socket-level subscription is fine because we close the electrum
// connection right after in the `finally` block.
const [utxoLists, tipHeader] = await Promise.all([
Promise.all(scripthashes.map((sh) =>
el.call("blockchain.scripthash.listunspent", [sh, "include_tokens"]).catch(() => [])
)),
el.call("blockchain.headers.subscribe", []).catch(() => null),
]);
const categories = new Set();
let sats = 0n;
for (const list of utxoLists) {
for (const u of list) {
// Sum spendable balance across every watched address.
const v = u.value;
if (typeof v === "number") sats += BigInt(v);
else if (typeof v === "string") sats += BigInt(v);
if (u.token_data?.nft) categories.add(u.token_data.category);
}
}
// Cross-join against the name index (REG NFTs) and the TLD snapshot
// (TREG NFTs). Both share the same "category → entry" shape; the
// wallet just holds one or the other.
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));
// TLDs — walk the TLD_BEACON snapshot (already cached by
// getTldSnapshot) and pick out categories owned by this wallet.
// Silent on a snapshot fetch failure — never let a beacon hiccup
// break the names response.
const tlds = [];
try {
const snap = await getTldSnapshot();
for (const t of snap.tlds ?? []) {
if (!categories.has(t.category)) continue;
tlds.push({
tld: t.tld,
category: t.category,
records: t.records ?? null,
reg_height: t.regHeight ?? null,
reg_txid: t.regTxid ?? null,
updated_txid: t.updatedTxid ?? null,
owner: t.owner ?? null,
});
}
tlds.sort((a, b) => a.tld.localeCompare(b.tld));
} catch { /* leave tlds empty */ }
const wallet = {
sats: sats.toString(), // BigInt-safe wire format
utxo_count: utxoLists.reduce((n, l) => n + l.length, 0),
};
const chain = tipHeader && typeof tipHeader.height === "number"
? { height: tipHeader.height }
: null;
const body = req.method === "POST"
? { count: names.length, tld_count: tlds.length, scripthashes_queried: scripthashes.length,
names, tlds, wallet, chain }
: { scripthash: scripthashes[0], count: names.length, tld_count: tlds.length,
names, tlds, wallet, chain };
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,
owner: entry?.owner ?? null,
}));
} catch (e) {
res.writeHead(400); res.end(JSON.stringify({ error: e.message }));
}
return;
}
// --- signed records manifest ---
// GET /api/records/<name> -> reads the current signed manifest from
// the name's Sia bucket at _records.json.
// Public. See DESIGN-signed-records-manifest.md.
// POST /api/records/<name> body = manifest JSON, verifies:
// (1) sig matches current on-chain NFT owner,
// (2) seq strictly greater than current seq,
// (3) name field matches path,
// then uploads to Sia at _records.json.
if (req.url.startsWith("/api/records/")) {
res.setHeader("access-control-allow-origin", "*");
res.setHeader("access-control-allow-methods", "GET, POST, OPTIONS");
res.setHeader("access-control-allow-headers", "content-type");
res.setHeader("access-control-max-age", "86400");
res.setHeader("content-type", "application/json");
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
const raw = decodeURIComponent(req.url.slice("/api/records/".length).split("?")[0]);
let name;
try { name = normalizeName(raw); }
catch (e) { res.writeHead(400); res.end(JSON.stringify({ error: e.message })); return; }
// Where the manifest lives — same Sia bucket the on-chain s3 record points at.
// If the name has no s3 pointer, records-manifest can't be published for it.
const entry = (await getIndex()).get(name);
if (!entry) { res.writeHead(404); res.end(JSON.stringify({ error: `"${name}" not registered` })); return; }
const s3Base = entry.records?.s3;
if (!s3Base) {
res.writeHead(409);
res.end(JSON.stringify({ error: `no s3 pointer on chain — set records.s3 first, then records-manifest has somewhere to live` }));
return;
}
const manifestKey = s3Base.endsWith("/") ? s3Base + "_records.json" : s3Base + "/_records.json";
// ---- GET ----
if (req.method === "GET") {
try {
const up = await aws.fetch(`${S3_ENDPOINT}/${manifestKey}`);
if (up.status === 404 || up.status === 403) { res.writeHead(404); res.end(JSON.stringify({ error: "no manifest yet", name })); return; }
if (!up.ok) { res.writeHead(502); res.end(JSON.stringify({ error: `sia read failed: ${up.status}` })); return; }
const body = await up.text();
res.writeHead(200); res.end(body);
} catch (e) {
res.writeHead(502); res.end(JSON.stringify({ error: `sia error: ${e.message}` }));
}
return;
}
// ---- POST ----
if (req.method !== "POST") { res.writeHead(405); res.end(JSON.stringify({ error: "GET or POST" })); return; }
if (!entry.owner) {
res.writeHead(503);
res.end(JSON.stringify({ error: "gateway index has no owner recorded for this name yet — retry after next index refresh (30s)" }));
return;
}
try {
// Read body (128 KB cap).
const chunks = [];
let total = 0;
for await (const c of req) {
total += c.length;
if (total > 128 * 1024) { res.writeHead(413); res.end(JSON.stringify({ error: "manifest > 128 KB" })); return; }
chunks.push(c);
}
const bodyText = Buffer.concat(chunks).toString("utf8");
let manifest;
try { manifest = JSON.parse(bodyText); }
catch { res.writeHead(400); res.end(JSON.stringify({ error: "invalid JSON" })); return; }
// Schema checks.
if (manifest.v !== 1) { res.writeHead(400); res.end(JSON.stringify({ error: "unsupported v (expected 1)" })); return; }
if (manifest.name !== name) { res.writeHead(400); res.end(JSON.stringify({ error: `name field "${manifest.name}" does not match path "${name}"` })); return; }
if (typeof manifest.seq !== "number" || !Number.isFinite(manifest.seq) || manifest.seq < 0) {
res.writeHead(400); res.end(JSON.stringify({ error: "seq must be a non-negative integer" })); return;
}
if (typeof manifest.sig !== "string" || !manifest.sig) {
res.writeHead(400); res.end(JSON.stringify({ error: "sig required (base64 BCH message signature, 65 bytes)" })); return;
}
// Seq monotonicity — read the current manifest if any.
try {
const cur = await aws.fetch(`${S3_ENDPOINT}/${manifestKey}`);
if (cur.ok) {
const cj = JSON.parse(await cur.text());
if (typeof cj.seq === "number" && manifest.seq <= cj.seq) {
res.writeHead(409); res.end(JSON.stringify({ error: `stale seq ${manifest.seq} <= current ${cj.seq}` })); return;
}
}
} catch { /* first write */ }
// Signature verification. The signable payload is the manifest
// WITHOUT the sig field, canonicalized: sorted keys, no whitespace,
// nulls omitted. See DESIGN-signed-records-manifest.md.
const canonical = JSON.stringify(canonicalize(withoutKey(manifest, "sig")));
const digest = sha256.hash(utf8ToBin(canonical));
let sigBytes;
try { sigBytes = base64ToBin(manifest.sig); }
catch { res.writeHead(400); res.end(JSON.stringify({ error: "sig is not valid base64" })); return; }
if (sigBytes.length !== 65) {
res.writeHead(400); res.end(JSON.stringify({ error: `sig must decode to 65 bytes (got ${sigBytes.length})` })); return;
}
// BCH message-signature envelope: [flag, r|s]. flag = 27+recovery
// (+4 if compressed pubkey). Compressed is the standard.
const flag = sigBytes[0];
const recovery = (flag - 27) & 3;
const compactSig = sigBytes.slice(1);
const recovered = secp256k1.recoverPublicKeyCompressed(compactSig, recovery, digest);
if (typeof recovered === "string") {
res.writeHead(400); res.end(JSON.stringify({ error: `sig recovery failed: ${recovered}` })); return;
}
// Derive p2pkh CashAddress from recovered pubkey, using the same
// prefix as the on-chain owner address (chipnet = "bchtest",
// mainnet = "bitcoincash"). The prefix comes from the owner
// string itself so we don't have to hardcode chain.
const ownerPrefix = String(entry.owner).split(":")[0] || "bchtest";
const pubkeyHash = ripemd160.hash(sha256.hash(recovered));
const _enc = encodeCashAddress({ prefix: ownerPrefix, type: "p2pkh", payload: pubkeyHash });
// libauth returns { address } in newer versions and a bare string in
// older ones — unwrap either shape.
const derivedAddr = typeof _enc === "string" ? _enc : (_enc?.address ?? "");
if (derivedAddr !== entry.owner) {
res.writeHead(403);
res.end(JSON.stringify({
error: "signature does not match current on-chain NFT owner",
derived: derivedAddr,
current: entry.owner,
}));
return;
}
// Upload signed blob to Sia at `_records.json`.
const put = await aws.fetch(`${S3_ENDPOINT}/${manifestKey}`, {
method: "PUT",
body: Buffer.from(bodyText, "utf8"),
headers: { "content-type": "application/json" },
});
if (!put.ok) {
const detail = await put.text().catch(() => "");
res.writeHead(502);
res.end(JSON.stringify({ error: `sia write failed: ${put.status} ${detail.slice(0, 200)}` }));
return;
}
// Uploaded — bump the highest-seen seq for this name so
// subsequent GET /api/dns/<name> won't accept a replay under any
// circumstances (Sia's read-consistency + our seq cache both agree).
_dnsSeqCache.set(name, manifest.seq);
res.writeHead(200);
res.end(JSON.stringify({ ok: true, name, seq: manifest.seq, sia_key: manifestKey, bytes: total }));
} catch (e) {
res.writeHead(500);
res.end(JSON.stringify({ error: `records-manifest error: ${e.message}` }));
}
return;
}
// --- marketplace: atomic name sales ---
// A listing is a seller-signed partial transaction (see market-tx.js).
// GET /api/market -> { count, listings: [...] } (spent/invalid ones pruned)
// POST /api/market body=listing -> verifies signature, ownership and that the
// certificate UTXO is unspent, then stores it
// DELETE /api/market/<name> -> owner-signed (x-bns-ts / x-bns-sig over
// sha256("BNS-MARKET1\n<name>\n<ts>"))
// GET /api/market/<name> -> one listing
if (req.url === "/api/market" || req.url.startsWith("/api/market/") || req.url.startsWith("/api/market?")) {
res.setHeader("access-control-allow-origin", "*");
res.setHeader("access-control-allow-methods", "GET, POST, DELETE, OPTIONS");
res.setHeader("access-control-allow-headers", "content-type, x-bns-sig, x-bns-ts");
res.setHeader("content-type", "application/json");
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
const one = req.url.startsWith("/api/market/") ? decodeURIComponent(req.url.slice("/api/market/".length).split("?")[0]) : "";
try {
if (req.method === "GET") {
const listings = await activeListings();
if (one) {
const name = normalizeName(one);
const l = listings.find((x) => x.name === name);
if (!l) { res.writeHead(404); res.end(JSON.stringify({ error: "not for sale" })); return; }
res.writeHead(200); res.end(JSON.stringify(l, null, 2)); return;
}
res.setHeader("cache-control", "public, max-age=15");
res.writeHead(200); res.end(JSON.stringify({ count: listings.length, listings }, null, 2)); return;
}
if (req.method === "POST" && !one) {
const chunks = []; let total = 0;
for await (const c of req) { total += c.length; if (total > 64 * 1024) { res.writeHead(413); res.end(JSON.stringify({ error: "listing > 64 KB" })); return; } chunks.push(c); }
let listing; try { listing = JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { res.writeHead(400); res.end(JSON.stringify({ error: "invalid JSON" })); return; }
const name = normalizeName(String(listing?.name || ""));
const entry = (await getIndex()).get(name);
if (!entry) { res.writeHead(404); res.end(JSON.stringify({ error: `"${name}" not registered` })); return; }
if (!entry.owner) { res.writeHead(503); res.end(JSON.stringify({ error: "index has no owner for this name yet — retry in 30s" })); return; }
const ownerLock = lockingHexOf(entry.owner);
const check = verifyListing(listing, { category: entry.category, ownerLockingBytecode: ownerLock });
if (!check.ok) { res.writeHead(400); res.end(JSON.stringify({ error: "listing rejected: " + check.error })); return; }
const unspent = await outpointUnspent(ownerLock, listing.source_output.txid, listing.source_output.vout);
if (!unspent) { res.writeHead(409); res.end(JSON.stringify({ error: "the certificate UTXO in this listing is already spent" })); return; }
const stored = {
v: 1, name, tld: name.includes(".") ? name.split(".").pop() : "bch", seller: entry.owner, category: entry.category,
price_sats: String(check.price_sats), partial_tx: listing.partial_tx, source_output: listing.source_output,
records: entry.records ?? {}, created_at: new Date().toISOString(),
};
const all = readMarket().filter((x) => x.name !== name);
all.push(stored); writeMarket(all); _marketCheck = { at: 0, list: null };
res.writeHead(200); res.end(JSON.stringify({ ok: true, listing: stored })); return;
}
if (req.method === "DELETE" && one) {
const name = normalizeName(one);
const entry = (await getIndex()).get(name);
if (!entry?.owner) { res.writeHead(404); res.end(JSON.stringify({ error: "unknown name" })); return; }
const ts = Number(req.headers["x-bns-ts"] || 0);
if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > 10 * 60 * 1000) { res.writeHead(401); res.end(JSON.stringify({ error: "x-bns-ts missing or stale" })); return; }
const who = recoverSigner(String(req.headers["x-bns-sig"] || ""), `BNS-MARKET1\n${name}\n${ts}`, entry.owner);
if (who !== entry.owner) { res.writeHead(403); res.end(JSON.stringify({ error: "signature does not match current on-chain NFT owner" })); return; }
writeMarket(readMarket().filter((x) => x.name !== name)); _marketCheck = { at: 0, list: null };
res.writeHead(200); res.end(JSON.stringify({ ok: true, name, removed: true })); return;
}
res.writeHead(405); res.end(JSON.stringify({ error: "GET, POST or DELETE" }));
} catch (e) { res.writeHead(500); res.end(JSON.stringify({ error: `market error: ${e.message}` })); }
return;
}
// --- TLD certificate UTXO (for co-signed registrations) ---
// GET /api/tld-cert/<tld> -> { tld, owner, utxo: {txid, vout, satoshis, lockingBytecode, token} }
if (req.url.startsWith("/api/tld-cert/")) {
res.setHeader("access-control-allow-origin", "*");
res.setHeader("content-type", "application/json");
const tld = decodeURIComponent(req.url.slice("/api/tld-cert/".length).split("?")[0]).toLowerCase();
try {
const snap = await getTldSnapshot();
const t = (snap.tlds || []).find((x) => x.tld === tld);
if (!t) { res.writeHead(404); res.end(JSON.stringify({ error: `.${tld} is not registered` })); return; }
if (!t.owner) { res.writeHead(503); res.end(JSON.stringify({ error: "no owner recorded" })); return; }
const utxo = await findTokenUtxo(lockingHexOf(t.owner), t.category, Buffer.from(tld, "utf8").toString("hex"));
if (!utxo) { res.writeHead(404); res.end(JSON.stringify({ error: "certificate UTXO not found at the recorded owner (moved?)" })); return; }
res.setHeader("cache-control", "no-store");
res.writeHead(200); res.end(JSON.stringify({ tld, owner: t.owner, category: t.category, utxo }, null, 2));
} catch (e) { res.writeHead(500); res.end(JSON.stringify({ error: e.message })); }
return;
}
// --- co-sign queue: registrations under private TLDs awaiting the TLD owner ---
// POST /api/cosign body { name, tld, buyer, tx, sourceOutputs, note? } -> { id }
// GET /api/cosign?tld=<tld> -> pending requests for that TLD (owner's dashboard)
// GET /api/cosign/<id> -> one request (buyer polls for status)
// DELETE /api/cosign/<id> -> TLD-owner-signed ("BNS-COSIGN1\n<id>\n<ts>") after approving
// (or the buyer, signed with the buyer address)
// The gateway stores and relays; it signs nothing. The owner signs the TLD
// certificate input in the dashboard and broadcasts.
if (req.url === "/api/cosign" || req.url.startsWith("/api/cosign/") || req.url.startsWith("/api/cosign?")) {
res.setHeader("access-control-allow-origin", "*");
res.setHeader("access-control-allow-methods", "GET, POST, DELETE, OPTIONS");
res.setHeader("access-control-allow-headers", "content-type, x-bns-sig, x-bns-ts");
res.setHeader("content-type", "application/json");
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
const u = new URL(req.url, "http://x");
const id = req.url.startsWith("/api/cosign/") ? decodeURIComponent(req.url.slice("/api/cosign/".length).split("?")[0]) : "";
try {
const all = readCosign().filter((r) => Date.now() - Date.parse(r.created_at) < 7 * 24 * 3600 * 1000);
if (req.method === "GET") {
if (id) { const r = all.find((x) => x.id === id); if (!r) { res.writeHead(404); res.end(JSON.stringify({ error: "not found" })); return; } res.writeHead(200); res.end(JSON.stringify(r, null, 2)); return; }
const tld = (u.searchParams.get("tld") || "").toLowerCase();
const list = tld ? all.filter((r) => r.tld === tld) : all.map((r) => ({ id: r.id, name: r.name, tld: r.tld, buyer: r.buyer, created_at: r.created_at }));
res.setHeader("cache-control", "no-store");
res.writeHead(200); res.end(JSON.stringify({ count: list.length, requests: list }, null, 2)); return;
}
if (req.method === "POST" && !id) {
const chunks = []; let total = 0;
for await (const c of req) { total += c.length; if (total > 256 * 1024) { res.writeHead(413); res.end(JSON.stringify({ error: "request > 256 KB" })); return; } chunks.push(c); }
let body; try { body = JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { res.writeHead(400); res.end(JSON.stringify({ error: "invalid JSON" })); return; }
const name = normalizeName(String(body?.name || ""));
const tld = name.includes(".") ? name.split(".").pop() : "bch";
if (!/^[0-9a-f]+$/i.test(String(body.tx || "")) || !Array.isArray(body.sourceOutputs) || !body.buyer) { res.writeHead(400); res.end(JSON.stringify({ error: "need { name, buyer, tx (hex), sourceOutputs[] }" })); return; }
if ((await getIndex()).has(name)) { res.writeHead(409); res.end(JSON.stringify({ error: `"${name}" is already registered` })); return; }
const rid = createHash("sha256").update(body.tx).digest("hex").slice(0, 16);
const rec = { id: rid, name, tld, buyer: String(body.buyer), tx: body.tx, sourceOutputs: body.sourceOutputs, note: String(body.note || "").slice(0, 280), created_at: new Date().toISOString() };
writeCosign([...all.filter((r) => r.id !== rid), rec]);
res.writeHead(200); res.end(JSON.stringify({ ok: true, id: rid })); return;
}
if (req.method === "DELETE" && id) {
const r = all.find((x) => x.id === id);
if (!r) { res.writeHead(404); res.end(JSON.stringify({ error: "not found" })); return; }
const ts = Number(req.headers["x-bns-ts"] || 0);
if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > 10 * 60 * 1000) { res.writeHead(401); res.end(JSON.stringify({ error: "x-bns-ts missing or stale" })); return; }
const snap = await getTldSnapshot();
const t = (snap.tlds || []).find((x) => x.tld === r.tld);
const allowed = [t?.owner, r.buyer].filter(Boolean);
const sig = String(req.headers["x-bns-sig"] || "");
const ok = allowed.some((addr) => recoverSigner(sig, `BNS-COSIGN1\n${id}\n${ts}`, addr) === addr);
if (!ok) { res.writeHead(403); res.end(JSON.stringify({ error: "only the TLD owner or the buyer can remove this request" })); return; }
writeCosign(all.filter((x) => x.id !== id));
res.writeHead(200); res.end(JSON.stringify({ ok: true, id, removed: true })); return;
}
res.writeHead(405); res.end(JSON.stringify({ error: "GET, POST or DELETE" }));
} catch (e) { res.writeHead(500); res.end(JSON.stringify({ error: `cosign error: ${e.message}` })); }
return;
}
// --- site files (Sirius Studio) ---
// The name's own folder on Sia is bns/<name>/. Writes go there and ONLY
// there, whatever the on-chain s3 record says — so no name can be used
// to overwrite another name's bucket.
// GET /api/site/<name> -> { name, prefix, files: [{path,size,modified}] }
// GET /api/site/<name>/<path> -> the object (read-through)
// PUT /api/site/<name>/<path> -> body = file bytes; headers:
// x-bns-ts unix ms, within 10 minutes of now
// x-bns-sig base64 65-byte BCH message signature over
// sha256("BNS-SITE1\n<name>\n<path>\n<sha256hex(body)>\n<ts>")
// DELETE /api/site/<name>/<path> -> same signature scheme, empty body
// The signature must recover to the name's current on-chain NFT owner
// (same check as /api/records). 8 MB per file.
if (req.url.startsWith("/api/site/")) {
res.setHeader("access-control-allow-origin", "*");
res.setHeader("access-control-allow-methods", "GET, PUT, DELETE, OPTIONS");
res.setHeader("access-control-allow-headers", "content-type, x-bns-sig, x-bns-ts");
res.setHeader("access-control-max-age", "86400");
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
const rest = req.url.slice("/api/site/".length).split("?")[0];
const slash = rest.indexOf("/");
const rawName = decodeURIComponent(slash < 0 ? rest : rest.slice(0, slash));
const filePath = slash < 0 ? "" : decodeURIComponent(rest.slice(slash + 1));
let name;
try { name = normalizeName(rawName); }
catch (e) { res.setHeader("content-type", "application/json"); res.writeHead(400); res.end(JSON.stringify({ error: e.message })); return; }
const entry = (await getIndex()).get(name);
if (!entry) { res.setHeader("content-type", "application/json"); res.writeHead(404); res.end(JSON.stringify({ error: `"${name}" not registered` })); return; }
const prefix = `bns/${name}/`;
// GET ...?src=live — a READ-ONLY view of wherever the name's on-chain
// records say the site lives right now: a CLI-published bucket such as
// bns/<label>/ (the `s3` record) or inline HTML (the `h` record). Sirius
// Studio uses it to import the current build. Writes never look at it;
// they stay confined to bns/<name>/.
const srcLive = req.method === "GET" && new URL(req.url, "http://x").searchParams.get("src") === "live";
const recS3raw = typeof entry.records?.s3 === "string" ? entry.records.s3.trim().replace(/^\/+/, "").replace(/\/?$/, "/") : "";
const livePrefix = srcLive && /^bns\/[A-Za-z0-9._\-\/]{1,200}$/.test(recS3raw) && !recS3raw.includes("..") ? recS3raw : null;
const liveInline = srcLive && !livePrefix && typeof entry.records?.h === "string" && entry.records.h ? entry.records.h : null;
const SITE_TYPES = { html: "text/html; charset=utf-8", htm: "text/html; charset=utf-8", css: "text/css; charset=utf-8", js: "text/javascript; charset=utf-8", mjs: "text/javascript; charset=utf-8", json: "application/json", txt: "text/plain; charset=utf-8", md: "text/markdown; charset=utf-8", xml: "application/xml", svg: "image/svg+xml", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", avif: "image/avif", ico: "image/x-icon", woff: "font/woff", woff2: "font/woff2", ttf: "font/ttf", pdf: "application/pdf", mp3: "audio/mpeg", mp4: "video/mp4", webm: "video/webm", wasm: "application/wasm" };
const typeOf = (p) => SITE_TYPES[(p.split(".").pop() || "").toLowerCase()] || "application/octet-stream";
// ---- list ----
if (!filePath) {
res.setHeader("content-type", "application/json");
if (req.method !== "GET") { res.writeHead(405); res.end(JSON.stringify({ error: "GET" })); return; }
if (srcLive && !livePrefix) {
// Nothing on Sia for this name: inline HTML or no site at all.
res.writeHead(200);
res.end(JSON.stringify({ name, prefix: null, source: liveInline ? "inline" : "none", files: liveInline ? [{ path: "index.html", size: Buffer.byteLength(liveInline), modified: null, inline: true }] : [] }));
return;
}
const listPrefix = (srcLive ? livePrefix : prefix).slice("bns/".length);
try {
const up = await aws.fetch(`${S3_ENDPOINT}/bns?list-type=2&prefix=${encodeURIComponent(listPrefix)}&max-keys=1000`);
if (!up.ok) { res.writeHead(502); res.end(JSON.stringify({ error: `sia list failed: ${up.status}` })); return; }
const xml = await up.text();
const files = [];
const re = /<Contents>([\s\S]*?)<\/Contents>/g;
let m;
while ((m = re.exec(xml))) {
const key = /<Key>([^<]*)<\/Key>/.exec(m[1])?.[1] || "";
const size = Number(/<Size>(\d+)<\/Size>/.exec(m[1])?.[1] || 0);
const modified = /<LastModified>([^<]*)<\/LastModified>/.exec(m[1])?.[1] || null;
if (key.startsWith(listPrefix)) files.push({ path: key.slice(listPrefix.length), size, modified });
}
res.writeHead(200); res.end(JSON.stringify({ name, prefix: srcLive ? livePrefix : prefix, source: srcLive ? "live" : "studio", files }));
} catch (e) { res.writeHead(502); res.end(JSON.stringify({ error: `sia error: ${e.message}` })); }
return;
}
// ---- path sanity ----
if (!/^[A-Za-z0-9._\-\/]{1,200}$/.test(filePath) || filePath.includes("..") || filePath.startsWith("/") || filePath.endsWith("/")) {
res.setHeader("content-type", "application/json"); res.writeHead(400); res.end(JSON.stringify({ error: "invalid path" })); return;
}
const key = `${srcLive && livePrefix ? livePrefix : prefix}${filePath}`;
// ---- read-through ----
if (req.method === "GET") {
if (srcLive && !livePrefix) {
if (liveInline && filePath === "index.html") { res.setHeader("content-type", "text/html; charset=utf-8"); res.setHeader("cache-control", "no-cache"); res.writeHead(200); res.end(liveInline); return; }
res.setHeader("content-type", "application/json"); res.writeHead(404); res.end(JSON.stringify({ error: "the name has no Sia-hosted site" })); return;
}
try {
const up = await aws.fetch(`${S3_ENDPOINT}/${key}`);
if (up.status === 404 || up.status === 403) { res.setHeader("content-type", "application/json"); res.writeHead(404); res.end(JSON.stringify({ error: "not found" })); return; }
if (!up.ok) { res.setHeader("content-type", "application/json"); res.writeHead(502); res.end(JSON.stringify({ error: `sia read failed: ${up.status}` })); return; }
const buf = Buffer.from(await up.arrayBuffer());
res.setHeader("content-type", typeOf(filePath));
res.setHeader("cache-control", "no-cache");
res.writeHead(200); res.end(buf);
} catch (e) { res.setHeader("content-type", "application/json"); res.writeHead(502); res.end(JSON.stringify({ error: `sia error: ${e.message}` })); }
return;
}
res.setHeader("content-type", "application/json");
if (req.method !== "PUT" && req.method !== "DELETE") { res.writeHead(405); res.end(JSON.stringify({ error: "GET, PUT or DELETE" })); return; }
if (!entry.owner) { res.writeHead(503); res.end(JSON.stringify({ error: "gateway index has no owner recorded for this name yet — retry after next index refresh (30s)" })); return; }
// ---- body (8 MB cap) ----
const chunks = []; let total = 0;
for await (const c of req) {
total += c.length;
if (total > 8 * 1024 * 1024) { res.writeHead(413); res.end(JSON.stringify({ error: "file > 8 MB" })); return; }
chunks.push(c);
}
const body = req.method === "PUT" ? Buffer.concat(chunks) : Buffer.alloc(0);
// ---- signature ----
const ts = Number(req.headers["x-bns-ts"] || 0);
const sigB64 = String(req.headers["x-bns-sig"] || "");
if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > 10 * 60 * 1000) { res.writeHead(401); res.end(JSON.stringify({ error: "x-bns-ts missing or more than 10 minutes off" })); return; }
let sigBytes;
try { sigBytes = base64ToBin(sigB64); } catch { res.writeHead(400); res.end(JSON.stringify({ error: "x-bns-sig is not valid base64" })); return; }
if (sigBytes.length !== 65) { res.writeHead(400); res.end(JSON.stringify({ error: `x-bns-sig must decode to 65 bytes (got ${sigBytes.length})` })); return; }
const bodyHex = createHash("sha256").update(body).digest("hex");
const digest = sha256.hash(utf8ToBin(`BNS-SITE1\n${name}\n${filePath}\n${bodyHex}\n${ts}`));
const recovered = secp256k1.recoverPublicKeyCompressed(sigBytes.slice(1), (sigBytes[0] - 27) & 3, digest);
if (typeof recovered === "string") { res.writeHead(401); res.end(JSON.stringify({ error: `signature recovery failed: ${recovered}` })); return; }
const ownerPrefix = String(entry.owner).split(":")[0] || "bchtest";
const encAddr = encodeCashAddress({ prefix: ownerPrefix, type: "p2pkh", payload: ripemd160.hash(sha256.hash(recovered)) });
const derivedAddr = typeof encAddr === "string" ? encAddr : (encAddr?.address ?? "");
if (derivedAddr !== entry.owner) { res.writeHead(403); res.end(JSON.stringify({ error: "signature does not match current on-chain NFT owner", derived: derivedAddr, current: entry.owner })); return; }
// ---- write / delete ----
try {
if (req.method === "DELETE") {
const del = await aws.fetch(`${S3_ENDPOINT}/${key}`, { method: "DELETE" });
if (!del.ok && del.status !== 204 && del.status !== 404) { res.writeHead(502); res.end(JSON.stringify({ error: `sia delete failed: ${del.status}` })); return; }
res.writeHead(200); res.end(JSON.stringify({ ok: true, name, path: filePath, deleted: true }));
return;
}
const ct = String(req.headers["content-type"] || "").split(";")[0].trim() || typeOf(filePath);
const put = await aws.fetch(`${S3_ENDPOINT}/${key}`, { method: "PUT", body, headers: { "content-type": ct } });
if (!put.ok) { const detail = await put.text().catch(() => ""); res.writeHead(502); res.end(JSON.stringify({ error: `sia write failed: ${put.status} ${detail.slice(0, 200)}` })); return; }
// Drop the gateway's page cache for this name so the new file shows at once.
for (const k of [...cache.keys()]) if (k === name || k.startsWith(name + "/") || k.startsWith(name + "|")) cache.delete(k);
res.writeHead(200); res.end(JSON.stringify({ ok: true, name, path: filePath, bytes: total, sia_key: key }));
} catch (e) { res.writeHead(500); res.end(JSON.stringify({ error: `site write error: ${e.message}` })); }
return;
}
// --- resolver-side DNS read (verified) ---
// GET /api/dns/<name> -> { name, seq, updated_at, dns, verified_owner }
//
// This is the "resolver read" side of the signed-records-manifest flow.
// Fetches the name's _records.json from Sia, verifies:
// (1) sig recovers to the current NFT holder,
// (2) seq is >= the highest we've cached (rejects replays even if
// Sia serves stale data),
// then returns only the verified `dns` block. Callers (mail bridges,
// DNS shims, Theseus, Ariadne) can trust the response without doing
// their own on-chain lookups.
if (req.url.startsWith("/api/dns/")) {
res.setHeader("access-control-allow-origin", "*");
res.setHeader("access-control-allow-methods", "GET, OPTIONS");
res.setHeader("access-control-allow-headers", "content-type");
res.setHeader("access-control-max-age", "86400");
res.setHeader("content-type", "application/json");
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
if (req.method !== "GET") { res.writeHead(405); res.end(JSON.stringify({ error: "GET only" })); return; }
const raw = decodeURIComponent(req.url.slice("/api/dns/".length).split("?")[0]);
let name;
try { name = normalizeName(raw); }
catch (e) { res.writeHead(400); res.end(JSON.stringify({ error: e.message })); return; }
const entry = (await getIndex()).get(name);
if (!entry) { res.writeHead(404); res.end(JSON.stringify({ error: `"${name}" not registered` })); return; }
const s3Base = entry.records?.s3;
if (!s3Base) { res.writeHead(404); res.end(JSON.stringify({ error: "no manifest — name has no s3 pointer on chain", name })); return; }
if (!entry.owner) { res.writeHead(503); res.end(JSON.stringify({ error: "gateway index has no owner recorded yet — retry after next index refresh" })); return; }
const manifestKey = s3Base.endsWith("/") ? s3Base + "_records.json" : s3Base + "/_records.json";
try {
const up = await aws.fetch(`${S3_ENDPOINT}/${manifestKey}`);
if (up.status === 404 || up.status === 403) { res.writeHead(404); res.end(JSON.stringify({ error: "no manifest published yet", name })); return; }
if (!up.ok) { res.writeHead(502); res.end(JSON.stringify({ error: `sia read failed: ${up.status}` })); return; }
const bodyText = await up.text();
let manifest;
try { manifest = JSON.parse(bodyText); }
catch { res.writeHead(502); res.end(JSON.stringify({ error: "manifest on sia is not valid JSON" })); return; }
if (manifest.v !== 1) { res.writeHead(502); res.end(JSON.stringify({ error: `unsupported manifest v=${manifest.v}` })); return; }
if (manifest.name !== name) { res.writeHead(502); res.end(JSON.stringify({ error: "manifest name field does not match request" })); return; }
// Replay defence — reject anything with a seq we've seen a HIGHER
// value of. Newer manifests are always accepted.
const seenSeq = _dnsSeqCache.get(name);
if (seenSeq !== undefined && manifest.seq < seenSeq) {
res.writeHead(502); res.end(JSON.stringify({ error: `stale manifest — seq ${manifest.seq} < seen ${seenSeq}` })); return;
}
// Signature check — recover pubkey from sig+digest, derive
// CashAddress with the owner's prefix, compare to entry.owner.
if (typeof manifest.sig !== "string") { res.writeHead(502); res.end(JSON.stringify({ error: "manifest has no sig" })); return; }
const canonical = JSON.stringify(canonicalize(withoutKey(manifest, "sig")));
const digest = sha256.hash(utf8ToBin(canonical));
let sigBytes;
try { sigBytes = base64ToBin(manifest.sig); }
catch { res.writeHead(502); res.end(JSON.stringify({ error: "sig not valid base64" })); return; }
if (sigBytes.length !== 65) { res.writeHead(502); res.end(JSON.stringify({ error: `sig must be 65 bytes (got ${sigBytes.length})` })); return; }
const flag = sigBytes[0];
const recovery = (flag - 27) & 3;
const compactSig = sigBytes.slice(1);
const recovered = secp256k1.recoverPublicKeyCompressed(compactSig, recovery, digest);
if (typeof recovered === "string") { res.writeHead(502); res.end(JSON.stringify({ error: `sig recovery failed: ${recovered}` })); return; }
const ownerPrefix = String(entry.owner).split(":")[0] || "bchtest";
const pubkeyHash = ripemd160.hash(sha256.hash(recovered));
const _enc = encodeCashAddress({ prefix: ownerPrefix, type: "p2pkh", payload: pubkeyHash });
const derivedAddr = typeof _enc === "string" ? _enc : (_enc?.address ?? "");
if (derivedAddr !== entry.owner) {
res.writeHead(502);
res.end(JSON.stringify({ error: "manifest signature does not match current NFT owner", derived: derivedAddr, current: entry.owner }));
return;
}
// Verified. Cache the seq and hand back the DNS block.
_dnsSeqCache.set(name, manifest.seq);
res.setHeader("Cache-Control", "public, max-age=30");
res.writeHead(200);
res.end(JSON.stringify({
name,
seq: manifest.seq,
updated_at: manifest.updated_at ?? null,
dns: manifest.dns ?? {},
verified_owner: entry.owner,
}, null, 2));
} catch (e) {
res.writeHead(500);
res.end(JSON.stringify({ error: `dns resolve 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>`;
}