navigate.st: multi-TLD label search (gateway /api/labels/<label> + client picker)

Fixes the "type a bare label, find matches across every TLD" gap. Before this,
typing `poutakidis` in the navigate.st search box was silently pinned to
`.bch` (missing `poutakidis.de`); typing `coinspectrum` was likewise pinned
to `.bch` and missed the `.x` registration.

Gateway (Argus/src/gateway/public-gateway.mjs): new /api/labels/<label>
endpoint. Iterates the cached full-index Map (getIndex(), rebuilt every 30s)
and returns { label, matches: [{ name, tld, records, category }] } — bare
`<label>` entries are surfaced as `<label>.bch` in the response for clarity.
Zero cost on the live path — the label endpoint reuses the same index that
the record-picker already touches.

Client (navigate.st/index.html): three routes on submit —
  bare label      → /api/labels/<l>: 0 matches show "not registered, register at
                    Sirius"; 1 match redirects to /bns/<name>; 2+ matches show
                    an inline picker with a per-record-type badge (on-chain /
                    Sia / server / redirect).
  <label>.<tld…>  → /bns/<name> straight, no API call needed.
  free text       → DuckDuckGo, unchanged.

The picker + empty-state markup + CSS all live inside the single 15 KB page —
no external dependencies. On API failure the client falls back to the pre-fix
".bch guess" so the box stays useful if the gateway is unreachable.

Deployed. Live at https://navigate.st/api/labels/poutakidis (1 match, .de) and
https://navigate.st/api/labels/coinspectrum (2 matches, .bch + .x). Backup of
the previous VPS gateway at /opt/bns-gateway/public-gateway.mjs.bak.1787175583.
This commit is contained in:
Local Dev 2026-08-20 00:28:00 +02:00
parent 45a3b595ce
commit 8228bda939

View file

@ -253,6 +253,47 @@ const server = http.createServer(async (req, res) => {
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;
}
// --- subdomain form: <flattened>.bch.<SUB_BASE> ---
if (hostHeader.endsWith("." + SUB_BASE)) {
const inner = hostHeader.slice(0, -("." + SUB_BASE).length); // "<flattened>.bch" or "<flattened>"