feat(sirius-x): admin TLD hide/unhide + tabbed sign-in modal (4 direct actions)

Two features:

1. Operator can hide TLDs from the public /api/tlds listing so hidden
   TLDs stop appearing in name-search UIs. On-chain registrations
   under a hidden TLD keep resolving — this is a UX filter, not
   enforcement.

   Gateway (public-gateway.mjs):
   - Persistent HIDDEN_TLDS set backed by hidden-tlds.json next to
     the service script
   - GET /api/tld-visibility  -> {hidden:[...]}                (public)
   - POST /api/tld-visibility -> updates the list (operator-gated by
     Bearer BNS_OPERATOR_TOKEN env var; if unset, all writes refused
     so we default-deny)
   - /api/tlds filters out HIDDEN_TLDS; add ?include_hidden=1 to see
     everything (used by the admin panel to show all rows)
   - Operator token installed via systemd override on the VPS

   Admin panel:
   - New 'Operator token' card at the top of the TLD-registry section;
     token stored in sessionStorage (not localStorage) so a full
     browser close forgets it
   - Each TLD row got a 'Hidden from public' checkbox that POSTs on
     toggle and refreshes the table; failures roll back the checkbox
     and surface the error next to the token field

2. Wallet dropdown restored to 4 direct actions
   (Unlock / Create a wallet / Import a wallet / WizardConnect) and
   the shared mint/sign-in modal grew a tab strip so users can switch
   between the four wallet actions from any step without going back
   to a choice screen.

   register-flow.js:
   - renderTabs(active) prepended to stepCreate/stepImport/stepUnlock/
     stepExternal when signInOnly is set. Unlock tab only appears
     when a saved wallet exists.
   - Delegated click handler on the sheet routes tab clicks to the
     matching step; switching away from a live WC session tears it
     down first so we don't leak WebSockets.

   profile-menu.js:
   - Restored 4-item onboarding menu (Create/Import/WC plus Unlock
     when saved). Each item is a direct entry point; the tabbed modal
     lets the user pivot to any other option without closing.

Cache-buster bumped on all 8 sirius-x pages to ?v=20260908tabs.
This commit is contained in:
Local Dev 2026-09-08 02:19:41 +02:00
parent 502bcf4ad1
commit 2346320bad

View file

@ -34,6 +34,31 @@ const MOUNT = process.env.BNS_MOUNT || "/bns";
const PORT = Number(process.env.BNS_PORT || 8700); const PORT = Number(process.env.BNS_PORT || 8700);
// Names hidden from /api/search/ results — direct navigation still works. // Names hidden from /api/search/ results — direct navigation still works.
const HIDDEN_FROM_SEARCH = new Set(["syskypo.de"]); 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 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 SUB_BASE = (process.env.BNS_SUB_BASE || "bch.navigate.st").toLowerCase(); // subdomain suffix
const aws = new AwsClient({ const aws = new AwsClient({
@ -409,17 +434,25 @@ const server = http.createServer(async (req, res) => {
const advertisedRaw = (anchor?.records?.tlds ?? "").trim(); const advertisedRaw = (anchor?.records?.tlds ?? "").trim();
const advertised = advertisedRaw ? advertisedRaw.split(/\s+/) : []; const advertised = advertisedRaw ? advertisedRaw.split(/\s+/) : [];
const known = new Set([...advertised, ...observed.keys()]); const known = new Set([...advertised, ...observed.keys()]);
const tlds = [...known].sort().map((tld) => ({ // include_hidden=1 returns every TLD (used by the admin panel);
tld, // otherwise HIDDEN_TLDS are filtered from the public listing.
registered_count: observed.get(tld) || 0, const url = new URL(req.url, "http://x");
in_advertised: advertised.includes(tld), 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.writeHead(200);
res.end(JSON.stringify({ res.end(JSON.stringify({
source: "tlds.bch", source: "tlds.bch",
height: anchor?.height ?? null, height: anchor?.height ?? null,
updated_txid: anchor?.updatedTxid ?? anchor?.txid ?? null, updated_txid: anchor?.updatedTxid ?? anchor?.txid ?? null,
advertised, advertised,
hidden_count: HIDDEN_TLDS.size,
tlds, tlds,
}, null, 2)); }, null, 2));
} catch (e) { } catch (e) {
@ -428,6 +461,66 @@ const server = http.createServer(async (req, res) => {
return; 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 --- // --- registry API: dump every registered name ---
// GET /api/registry -> { count, height_hint, names: [{ name, tld, records, category, txid, height, updated_txid }] } // 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 // The bulk snapshot a name-selling site can cache to compute availability