diff --git a/src/gateway/public-gateway.mjs b/src/gateway/public-gateway.mjs index 0e79792..92736a1 100644 --- a/src/gateway/public-gateway.mjs +++ b/src/gateway/public-gateway.mjs @@ -34,6 +34,31 @@ const MOUNT = process.env.BNS_MOUNT || "/bns"; const PORT = Number(process.env.BNS_PORT || 8700); // Names hidden from /api/search/ results — direct navigation still works. const HIDDEN_FROM_SEARCH = new Set(["syskypo.de"]); + +// TLDs the operator has flagged "hidden" — kept off public /api/tlds +// listings so name-search UIs stop offering them. On-chain registrations +// under a hidden TLD still exist; this is a UX filter, not enforcement. +// Persisted to a JSON file next to this script so it survives restarts. +import { readFileSync as _readFile, writeFileSync as _writeFile, existsSync as _existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname as _dirname, join as _join } from "node:path"; +const HIDDEN_TLDS_FILE = process.env.BNS_HIDDEN_TLDS_FILE + || _join(_dirname(fileURLToPath(import.meta.url)), "hidden-tlds.json"); +// Operator gate — POSTs to /api/tld-visibility require a matching bearer. +// Set BNS_OPERATOR_TOKEN in the service env; if unset, the endpoint refuses +// every write (safer than allowing anonymous writes to a public gateway). +const OPERATOR_TOKEN = process.env.BNS_OPERATOR_TOKEN || null; +function readHiddenTlds() { + try { + if (!_existsSync(HIDDEN_TLDS_FILE)) return new Set(); + const raw = JSON.parse(_readFile(HIDDEN_TLDS_FILE, "utf8")); + return new Set(Array.isArray(raw?.hidden) ? raw.hidden : []); + } catch { return new Set(); } +} +function writeHiddenTlds(set) { + _writeFile(HIDDEN_TLDS_FILE, JSON.stringify({ hidden: [...set].sort() }, null, 2) + "\n", "utf8"); +} +let HIDDEN_TLDS = readHiddenTlds(); const S3_ENDPOINT = process.env.BNS_S3 || "http://127.0.0.1:8500"; const SUB_BASE = (process.env.BNS_SUB_BASE || "bch.navigate.st").toLowerCase(); // subdomain suffix const aws = new AwsClient({ @@ -409,17 +434,25 @@ const server = http.createServer(async (req, res) => { const advertisedRaw = (anchor?.records?.tlds ?? "").trim(); const advertised = advertisedRaw ? advertisedRaw.split(/\s+/) : []; const known = new Set([...advertised, ...observed.keys()]); - const tlds = [...known].sort().map((tld) => ({ - tld, - registered_count: observed.get(tld) || 0, - in_advertised: advertised.includes(tld), - })); + // include_hidden=1 returns every TLD (used by the admin panel); + // otherwise HIDDEN_TLDS are filtered from the public listing. + const url = new URL(req.url, "http://x"); + const includeHidden = url.searchParams.get("include_hidden") === "1"; + const tlds = [...known].sort() + .filter((tld) => includeHidden || !HIDDEN_TLDS.has(tld)) + .map((tld) => ({ + tld, + registered_count: observed.get(tld) || 0, + in_advertised: advertised.includes(tld), + hidden: HIDDEN_TLDS.has(tld) || undefined, + })); res.writeHead(200); res.end(JSON.stringify({ source: "tlds.bch", height: anchor?.height ?? null, updated_txid: anchor?.updatedTxid ?? anchor?.txid ?? null, advertised, + hidden_count: HIDDEN_TLDS.size, tlds, }, null, 2)); } catch (e) { @@ -428,6 +461,66 @@ const server = http.createServer(async (req, res) => { 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 + // 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