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.