Two symmetric changes so a site published as flat .html files (sirius.x
has tld.html, portal.html, market.html, register.html, studio.html)
reads with clean URLs to the visitor.
1. S3 handler fallback order for extensionless requests:
/tld → tld.html (new — first)
/tld → tld/index.html (existing — second)
/... → index.html (existing — SPA last-resort)
Requests with a real file extension (.js, .svg, .json) are never
rescued — those must never be routed by a client-side router and
swallowing them would hide real bugs.
2. transformHtml() gains stripHtmlExt(): rewrites href="foo.html"
(relative) to href="foo", preserving anchor and query. Absolute
URLs (https://silentmode.st/tlds/) are left alone — external
references are none of our business.
Backwards-compatible: existing /bns/<name>/foo.html links still 200.
Verified end-to-end on sirius.x: /tld, /portal, /market, /register,
/studio all serve; assets under js/, assets/ unchanged; direct .html
requests still work.
A BCH-priced offer is a signed transaction with a fixed amount, so a
seller who wants "$500" had to relist whenever the rate moved. The new
USD mode moves the certificate into a small CashScript covenant
(contracts/usd-listing.cash). Anyone may buy it by paying the seller
target_cents / price, where the price comes from a message signed by the
gateway's oracle key — the median of five exchanges the site already
uses. The payout is clamped to a floor/ceiling band the seller sets, and
the seller can reclaim the certificate at any time.
What the chain can and cannot enforce, stated plainly in the contract and
the UI: script can require a transaction to be mined no earlier than a
time, never no later, so a quote cannot expire on-chain. The band is the
guarantee, and quotes are bound to the listing's outpoint so cancelling
and relisting voids every quote ever issued. An offline libauth VM test
covers list, buy, underpayment, foreign-listing quotes, forged quotes,
band clamping, cancel and a non-seller cancel; on chipnet aloevera.test
was listed at $0.30, bought by a second wallet for 119,090 sat at
$251.91, listed again by the new owner and cancelled.
Gateway: GET /api/price/oracle[?txid=] serves the oracle public key and
per-listing signed quotes; the key lives in oracle.key beside the
gateway, never in the repo. POST /api/market accepts kind "usd" after
reproducing the redeem script from the stated terms and finding the
certificate in that covenant; such listings are pruned once the covenant
output is spent; DELETE accepts the listing's seller while the index
shows the covenant as holder.
Dashboard: the Sell tab has "Price is fixed in: US dollars / BCH", a
band control with the resulting BCH range, and cancel through the
covenant. Market: USD listings show the dollar price, today's BCH
equivalent and the band; buying fetches a quote and completes the
covenant spend. Studio no longer sends a needless UPD when the registry
view is stale, and missing files on a name now get a real 404 page.
The market page had just moved to market/index.html and still referenced
market.js and pricing.js under their previous query strings, so browsers
kept the cached satoshi-priced versions. Tags are now in step across
every page. The gateway also sends cache-control max-age=120 for
everything it serves from Sia; those files carry no ETag, so browsers
were applying heuristic caching and holding old scripts for hours.
theseus.x had a hardcoded href="https://sirius.x/" that worked in Theseus
(which resolves BCH names natively) but 404'd for ordinary browsers
served through the relay. Fixing it in every source file each time a
site adds a cross-name link is thankless; fix it once at the gateway.
New transformHtml() = injectBase() + rewriteBnsLinks(). The rewrite
matches href="https?://<label>.<tld>/..." and, when <tld> is a known
BCDN-only TLD, replaces the URL with /bns/<host>/... so the click stays
inside the relay origin.
Allowlisted TLDs: bch, x, p2p, deviant, bit, nav, test, coin, cash.
Intentionally EXCLUDED: st, com, gt, sc, de — those exist in ICANN and
rewriting them would hijack real web references (dl.silentmode.st is
the working case). The rewrite only fires on gateway-served HTML, so
the source sites remain clean when consumed at their native origin
by Theseus or Ariadne.
Verified: https://sirius.x/ on theseus.x home + plug-ins pages now
becomes /bns/sirius.x/; ordinary dl.silentmode.st links unchanged.
Every dollar figure on the site came from a hard-coded 250,000 sats per
dollar, which implies $400 per BCH; the market is near $250, so name
prices, TLD fees and sale listings were shown about 60% too cheap in
dollars. The gateway now serves /api/price: the median of Coinbase,
Kraken, Bitstamp, Binance and CoinGecko public tickers, no keys, cached
60 s, last good answer kept if every source fails. The site reads it
first, queries the same tickers itself if the gateway is unreachable,
remembers the last rate per browser, and only falls back to a constant
for the very first paint. Pages repaint when the rate arrives, and the
rate line says where it came from and how old it is.
Units: satoshi no longer appear anywhere. Sale prices, the seller's
input and the buy dialog are in BCH with the dollar figure beside
them; balances, fees and dust are in bits (1 bit = 100 satoshi).
The path-form handler injected `<base href="/bns/<name>/">` on every
page regardless of the sub-path being viewed. That meant a subdirectory
page's relative-parent links resolved wrong: from /bns/theseus.x/extensions/
the page's `<a href="../plug-ins/">` was resolved against the base as
/bns/plug-ins/ — dropping the name, giving a 404.
Compute baseHref from the directory of the current sub-path instead
(everything before the last "/" in rest, defaulting to "/"). Now:
/bns/theseus.x/ → <base href="/bns/theseus.x/">
/bns/theseus.x/plug-ins/ → <base href="/bns/theseus.x/plug-ins/">
/bns/theseus.x/plug-ins/x.html → <base href="/bns/theseus.x/plug-ins/">
/bns/theseus.x/extensions/ → <base href="/bns/theseus.x/extensions/">
`../plug-ins/` from the extensions page now resolves correctly to
/bns/theseus.x/plug-ins/, and `./` on a subdirectory page resolves to
the subdirectory itself instead of the site root.
Verified end-to-end: clicking Plug-ins from Extensions preserves the
name in the URL. Assets loaded via `../assets/...` from any subdirectory
still resolve correctly (they now go up exactly one level, to the site
root).
Pages such as potidaea.asm put their background on <html> and leave
<body> transparent; the editor's base stylesheet paints <body> white,
so the imported page showed light text on white. The canvas body now
stays transparent, which also matches the exported page.
The gateway's public name route sends Access-Control-Allow-Origin so
Studio on sirius.x can fetch a page's stylesheets and inline them
instead of falling back to links; the route only serves public content.
The language picker had no toolbar to attach to in Studio and floated
over the blocks panel; pages can now mark a slot for it and Studio does.
Anyone who owns a BCDN name can now publish a Theseus extension, and every
Theseus can install it with the publisher's signature verified locally.
Gateway (Argus/src/gateway/public-gateway.mjs):
PUT /api/ext/<name>/<id>/<version> takes the gzipped tar, checks two BCH
message signatures against the name's current NFT owner (one authorises
the upload, one is stored in the channel), inspects the package
(addon.json at the root, id/version/main match, 8 MB cap), enforces
first-publisher ownership of an id and monotonic versions, and writes the
tarball, the extension's updates.json and community/catalog.json to Sia.
GET /api/ext/catalog reads the catalog back with CORS.
Theseus:
lib/publisher-sig.mjs recovers the signer of a channel entry; main.js
compares it with the publisher name's owner from Theseus's own chain
index before installing or updating, so neither the relay nor a tampered
catalog can pass off code under a trusted name. addon-updater.js gains
installCommunity() and accepts publisher-signed entries in the regular
update check (operator Ed25519 entries unchanged). Settings › Extensions
shows the community catalog with Install / Update; Settings › Plug-ins
links to theseus.x/plug-ins.
theseus.x:
/plug-ins/ is a separate page for the first-party plug-ins (Aegis,
Ariadne's Thread) with live versions and hashes; /extensions/ lists the
bundled extensions, the community catalog, and how to build and publish;
/extensions/publish/ signs and uploads a package in the browser with the
wallet that holds the publisher's name (session helper + wallet bundle
copied alongside).
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/.
Two gaps the owner panel left open. First, a hidden TLD was only a UI
gate: anyone could still broadcast a REG under it and every indexer
admitted it. Second, there was no way to sell a name without trusting
the other side.
Co-sign rule (consensus, applied in lockstep by bns.js and
resolver-web.js): a REG under a TLD whose records at that height say
policy "cosign" or hidden 1 is indexed only if the transaction carries
the TLD's own certificate. The certificate can only be spent by the
owner's key and is re-issued to them in the same transaction, so it is
a co-signature nobody can forge and nothing is consumed. The TLD map
now keeps the TUPD timeline so policy is evaluated at the REG height.
Owners register under their private TLDs with the certificate added
from their own wallet; third parties under a "cosign" TLD build the
full transaction, sign their inputs and queue it at /api/cosign, where
the owner approves it from the dashboard (signCosignRequest refuses to
sign unless the certificate returns to the same locking script).
Marketplace: a listing is the seller's certificate input plus a price
output signed SIGHASH_SINGLE|ANYONECANPAY, stored by the gateway as a
bulletin board (/api/market, verified against the on-chain owner and
pruned when the certificate moves). The buyer completes it in one
transaction, so the seller is paid exactly when the name moves.
Cancelling also spends the certificate once so the offer is void.
Site: market.html, Sell sub-tab and Pending approvals in the portal,
Market link in nav and footer, six dictionaries extended, cache tags
bumped. Verified on chipnet: cosigned.sc registered by a throwaway
wallet through the queue with the .sc certificate back at the owner;
aloevera.test listed and delisted through the API.
Ariadne's resolver-web.js copy and the mobile Bns.java port still need
the co-sign rule; until then they admit REGs this index rejects.
Owners had no way to put a real page behind a name without running Sia
tooling themselves. Sirius Studio (studio.html, js/studio.js) embeds
GrapesJS (BSD-3, vendored under vendor/grapesjs so nothing loads from a
third party) with three starter templates; Publish exports one
self-contained index.html plus the editor project, uploads both to the
name's own folder bns/<name>/ on Sia, and sets the s3 record on chain if
the name does not point there yet. Drafts save to the same folder.
Gateway: /api/site/<name>[/<path>] — list, read-through, PUT and DELETE.
Every write carries a wallet signature over (name, path, body hash,
timestamp) that must recover to the current NFT owner, and writes are
confined to bns/<name>/ so no name can touch another name's bucket.
Dashboard menu is now Web-Builder, Domain names, My TLDs, Settings, Sign
out; Overview and Wallet folded into Domain names and Settings, Docs is a
link in Settings.
The 17:23 UTC deploy today pushed the repo copy of the gateway over the
VPS, but that copy predated the signed-records work (/api/records,
/api/dns, TLD_BEACON-sourced /api/tlds, holdings with tlds/wallet/chain),
which had only ever lived on the VPS. Three-way merge of that version with
the SPA-fallback change, plus the resolver's NFT-owner tracking that the
records routes verify against. The repo is now the only deploy source.
Two-tier fallback for extensionless paths that miss in Sia. The
previous change went straight to root index.html, which meant a real
sub-site living at "<name>/about/index.html" got shadowed by the SPA
root — visitors requesting "/about" got served the app instead of
the landing page.
New chain (matches nginx try_files order):
1) request path → /about
hit: bns/potidaea/about → 404
2) directory index attempt → /about/
hit: bns/potidaea/about/index.html → 200 · SERVE
3) if still 404, SPA root → bns/potidaea/index.html → 200
Trailing-slash requests still short-circuit to their own index.html
via the existing `rel` derivation (unchanged), so /about/ has been
working the whole time; only the bare /about was falling through
to the SPA rescue.
Deployed to silentmode 2026-09-16:
scp Argus/src/gateway/public-gateway.mjs
silentmode:/tmp/public-gateway.mjs
sudo cp … /opt/bns-gateway/public-gateway.mjs
sudo systemctl restart bns-gateway
When a request to /bns/<name>/<path> misses in Sia AND the path has
no file extension (looks like a client-side route, not an asset),
serve the domain's own index.html instead. Same shape as nginx's
try_files … /index.html — lets any hosted site use the History API
for pretty URLs.
Extension-having requests (missing .png / .js / .json / …) still
404 as they should — those should never be routed by a client-side
router and swallowing them would hide real bugs.
Deployed to silentmode 2026-09-16:
scp Argus/src/gateway/public-gateway.mjs
silentmode:/tmp/public-gateway.mjs
sudo cp … /opt/bns-gateway/public-gateway.mjs
sudo systemctl restart bns-gateway
Companion Deviant commit ("widescreen swap + URL routing") uses
this to serve /bns/potidaea.asm/katalogos etc. from the same
index.html.
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.
Content-addressed image upload for the DVMB icon sidecar (see the
Deviant repo commit 'in-browser Sia upload' for the client side).
Request
POST /api/icon-upload
Content-Type: image/(svg+xml|png|jpeg|gif|webp|x-icon)
body: raw image bytes (≤ 128 KB)
Response
{ sha256, ext, path: "s3://bns/potidaea/icons/<sha256>.<ext>",
url: "https://navigate.st/bns/potidaea.asm/icons/<sha256>.<ext>",
bytes }
Why no auth
Content-addressed: the key IS the SHA-256, so a re-upload of the same
bytes overwrites itself harmlessly, and a spammer only pollutes their
own hash-space. The on-chain iconSha256 field (DVMB chunk 5) ties an
entry NFT to specific bytes — a rogue upload can't hijack another
asset's icon.
Guards
• content-type whitelist (only real image formats)
• 128 KB body cap enforced during read (early destroy on overflow)
• CORS * — safe, since we never touch per-user state
• OPTIONS preflight handled for browsers that trigger one
Deployed to silentmode 2026-09-06; live at
https://navigate.st/api/icon-upload
Smoke: browser JS POST returned 200 with the expected content-
addressed URL; a later GET of that URL returned matching bytes.
The sirius.x portal signs the user in with a seed phrase and then calls
POST /api/holdings to look up the BCNR names their wallet holds. That
endpoint was removed by commit 66fb0fe ("forward proxied headers +
trust Silent Mode CA") along with the elConnect/elCall helpers it
depended on. From a user's point of view the sign-in appeared broken:
the wallet unlocked but the names panel showed 'Could not load names:
API 404: bns gateway'.
Restores the endpoint using the connectAny + CHIPNET_ELECTRUM helpers
that resolver-web.js already had (now exported), and keeps the
beacon-only-URL filter so listunspent doesn't land on bns-indexer,
which only serves get_history + transaction.get.
Rebuild-in-place: no client-side change needed; portal.html already
POSTs to /api/holdings and expects the {count, names[]} shape this
endpoint returns.
/api/search/ now catches typos with a bounded-Levenshtein pass against
each label — max edit distance 1|2|3 depending on query length. Fuzzy
hits are labeled `matchType:"fuzzy"` and always rank below real matches
(scores 40|25|10 vs 100|70|50), so exact matches never get displaced by
a near-miss. Client shows a "closest name we found" header when the
whole result set is fuzzy, and a subtle amber "did you mean" badge per
row. Verified: hlelo → hello.bch, silntmode → silentmode.{bch,x},
pouakidis → poutakidis.de, and silentmode still returns label-exact
first with fuzzy hidden.
syskypo.de is now excluded from /api/search/ results (still reachable
via /api/name/ and /bns/…): it's a mirror of poutakidis-content and
was cluttering suggestions. Swapped it out of the placeholder too.
Two pieces from testing "login through hephaestus.x":
1. bns-gateway (public-gateway.mjs, ip: case) — forward the upstream's
response headers, not just content-type. The old code stripped Location,
Set-Cookie, and everything else, so redirects from a proxied hostname
died silently in the browser ("Temporary Redirect" body with no Location
header). Header set now respects RFC 7230 hop-by-hop exclusions and
drops Content-Length (we may rewrite HTML for baseHref).
2. Hephaestus stack:
- forgejo/Dockerfile: tiny extension of the official image that copies
the Silent Mode Argonautica root CA into the OS trust store, so
Forgejo's Go client can validate hephaestus.x's cert on OIDC discovery
calls. Deliberately no `USER git` at the end — parent's s6-overlay
needs to init as root.
- docker-compose.yml: switch to `build: ./forgejo` and add
`extra_hosts: - "hephaestus.x:host-gateway"` so Forgejo can resolve
hephaestus.x back to the host's Caddy.
Both changes are inert at the moment because Forgejo's ROOT_URL is back at
code.silentmode.st (browsers-without-BCNR-resolvers couldn't complete
OAuth through hephaestus.x because session cookies don't cross origins —
the working UX has the "Sign in with wallet" button on hephaestus.x
delegate to code.silentmode.st). Keep the infrastructure in place for when
Theseus/Ariadne adoption makes it viable to flip ROOT_URL properly.
gateway/public-gateway.mjs:
* GET /api/holdings/<scripthash> and POST /api/holdings {scripthashes:[…]}
— server-side wallet-holdings lookup for the portal. Bns-indexer is
beacon-only so it can't answer listunspent for arbitrary addresses; the
gateway does the electrum roundtrip and cross-joins with the cached BCNR
index. Client posts scripthashes (no libauth needed on the server) and
gets back the names owned.
* elConnect now falls through to whole-buffer JSON.parse when a Fulcrum
response lacks a trailing newline — chipnet.bch.ninja does this and
otherwise every elCall to it times out. Same tolerance pattern as
bns-indexer.test.mjs's tiny electrum client.
* verifyElectrum picks a full electrum with a 3s server.version probe so a
hung server fails over to the next in seconds instead of stalling 20.
* Skips silentmode.st/electrum and coinspectrum.duckdns.org:50011 for
/api/holdings — both are bns-indexer routes that don't do listunspent.
web/register-entry.js: export addressToScripthash so the portal can derive
the scripthash for each of its wallet's watched addresses before POSTing.
Browser bundle rebuilt.
site-sirius-x pages:
* favicon + logo hrefs made relative (./assets/… on index/portal,
../assets/… on docs/theseus). Absolute /sirius-x/… broke on the sirius.x
BCNR route where the site is served from root instead of under /sirius-x/.
* Roadmap nav link added back to portal, docs, theseus — the landing has an
anchor to a section on itself, so subpages now link to it explicitly.
* portal.html: loadNames rewritten to POST scripthashes to /api/holdings
instead of doing client-side electrum. Works on browsers whose networks
block chipnet electrum ports; same-origin HTTPS on 443.
* portal.html: bundle import ?v= bumped so Chrome's in-memory ES module
map picks up the new bundle instead of a stale cached copy.
- /api/tlds now calls buildTldSnapshot on the per-TLD-NFT beacon
(Decentralized.DNS/DESIGN-tld-registry.md §3) instead of reading the
legacy tlds.bch record. Every entry carries `certificate: true|false`
so a name shop can distinguish covenanted, sellable TLDs from legacy
ones that predate the certificate model. Ships a `legend` field
explaining both, and hides intentionally-legacy TLDs (currently just
`silentmode`, from an accidental REG) unless `?hidden=1`.
- /api/registry — bulk dump of every registered name for offline
availability computation by registrar sites.
- Inline electrum client (~30 lines) so the endpoint can query an
arbitrary scripthash — resolver-web's Electrum class is not exported.
Prefers the local multi-beacon indexer at ws://127.0.0.1:50010 with
public-pool fallback.
silentmode.bch/tools/ was returning the Sia NoSuchKey XML because the
gateway only auto-appended index.html for the root path (/), not for
sub-directory paths (/tools/, /apps/, etc). Trailing-slash URLs now
resolve to <path>/index.html — matches Ariadne's bnsd.js which already
had the same rewrite on the client side.
Verified: /bns/silentmode.bch/tools/ returns 200 with the tools page
(was 404 NoSuchKey).
Fixes the user-reported "download link on silentmode.bch/tools/#theseus
shows an XML NoSuchKey error" — the browser was requesting /tools/
(trailing slash from the URL path), gateway looked up bucket key
bns/silentmode/tools/ which doesn't exist, forwarded the S3 error.
Deployed on the VPS (/opt/bns-gateway/) with the in-place edit path
that keeps the same-dir import shape there (repo has ../lib/, VPS has
./resolver-web.js — the two file layouts intentionally differ).
Adds a unified /api/search/<q> endpoint to the gateway and rebuilds
navigate.st's search box + featured cards around the new "6 curated names,
everything else via search" IA.
Gateway (Argus/src/gateway/public-gateway.mjs): /api/search/<q> iterates the
cached full-index Map and scores every match — label-exact (100) beats
label-substring (70) beats h-record content substring (50). Content search
strips HTML tags before matching and returns a ±40/60-char snippet around
each hit. Results sorted by score then name, capped at 50, exposes a
`truncated` flag. Same 30-second cache the record picker + /api/labels/
already use — no extra fetch on the hot path. s3/ip content search awaits
Helios v1 (deferred by design).
Client (navigate.st/index.html): submit now routes three ways —
<label>.<tld…> → /bns/<name> straight, no API call
everything else → /api/search/<q>, render ranked panel
‣ 0 hits → "not on chain — Register at Sirius · Search the web"
‣ 1 label-exact hit → auto-redirect (fast path preserved)
‣ otherwise → results panel with match-type badge, snippet, mark-
highlighted query term
The old bare-label /api/labels/ path is retired from navigate.st (the endpoint
stays live — Sirius registration availability still uses it).
Featured cards on the main page are now the six user-selected names:
hello.bch, coinspectrum.x, silentmode.x, theseus.x, game.x, deviant.x.
Three are already on chain (hello.bch / coinspectrum.x / game.x) and render
as normal cards; three are not yet registered (silentmode.x / theseus.x /
deviant.x) and render as dashed "coming soon" pending cards — non-clickable,
faded, prevents 404s until they're actually minted via Sirius. The old 8-name
Quick Links grid is gone; "everything else via search" is now the story.
Deployed. Live search verified with poutakidis (1 hit → auto-redirect),
deviant (3 hits including a content match), xyzzynomatch (empty-state CTA).
Gateway rollback point at /opt/bns-gateway/public-gateway.mjs.bak.1787246702.
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.
Uncovered by the 2026-08-13 subdomain-inheritance fix: once
`checkers.game.x` correctly picked the parent's `ip` record instead of
`s3`, the ip branch itself failed. Two reasons:
1. `fetch("http://<ip>/", { headers: { host: name } })` follows the
site's :80→:443 redirect into `https://<name>.<tld>/`, which isn't
in ICANN DNS → "fetch failed".
2. The site's cert is signed by a per-machine BNS root, not a public
CA; standard TLS validation rejects it.
Both are fixed by connecting to the IP with SNI = name, pinning the
presented cert's SHA-256 against the on-chain `tls` record, and only
then issuing the HTTPS request over the same socket. The on-chain
fingerprint is the trust anchor BNS uses everywhere else (see
Argus/src/lib/ca.js).
Gateway (public-gateway.mjs): new pinnedHttpsGet + httpGet + ipRequest
helpers; case "ip" delegates. No silent HTTP fallback on pin failure
(a mismatch means "not the site the chain says it is").
Theseus (main.js): parallel port of the same helpers, Tor-aware
(routes through SocksProxyAgent when Tor is on). serveBns's inner
serveIp() delegates to ipRequest.
Verified live: `curl -sI https://navigate.st/bns/checkers.game.x/`
returns 200 OK with the checkers game (1,179,215 bytes, apex
`game.x` unchanged, served from Sia).
Symptom: pasting https://navigate.st/search?q=coinspectrum.bch (or the
new default-search-engine redirect from any browser) returned 404 from
nginx. Same for any BCNR name whose on-chain record uses ip (h / s3 / p
were fine).
Cause: the gateway's ip branch used fetch() with { headers: { host: name } }.
WHATWG fetch defines Host as a forbidden header, so undici silently
drops it and sends Host: <ip>. nginx has no server_name for a bare IP,
falls to the default server, 404s. The whole point of the ip record is
to reach a server_name that matches the BCNR name, so Host must be set.
Fix: use http.request from node:http (no forbidden-header list) with an
explicit Host header. Same behavior for every ip record; nothing changes
for h / s3 / p / u branches.
Live-patched on the VPS gateway and restarted the bns-gateway systemd
service. coinspectrum.bch now returns 200 (116 KB) via
navigate.st/bns/coinspectrum.bch/ and via the /search?q= redirect chain.
Argus/src/gateway/public-gateway.mjs mirrors the on-disk change on the
VPS at /opt/bns-gateway/public-gateway.mjs so future redeploys keep it.
Adds handleSearch() to public-gateway.mjs: users add
https://navigate.st/search?q=%s
as their browser's default search engine and typing bare "silentmode.bch"
in the URL bar (which Chromium and Firefox otherwise send to Google as a
search query, since .bch isn't in ICANN's TLD list) arrives here. Native
BNS TLDs (.bch, .p2p, .bit, .nav) 302 to the relay; anything else falls
through to DuckDuckGo so ordinary search still works.
Live on the VPS gateway at /opt/bns-gateway/public-gateway.mjs.
Nginx now proxies location = /search to 127.0.0.1:8700 as well.
Solves: bare "silentmode.bch" in Chrome/Edge went to Google. It now
resolves to the site via the relay in every browser.