2026-07-29 13:54:34 +02:00
// BNS public gateway (the "remote relay", convenience tier).
// Runs on the VPS. Resolves .bch from the chain (zero-dep resolver) and serves
// the content over plain HTTPS so ANY device's browser can view .bch sites with
// no install. Two URL forms:
// path form: https://navigate.st/bns/<name>.bch/... (<base> injected)
// subdomain form: https://<name-flattened>.bch.navigate.st/ (own origin, no <base>)
// where dots in a multi-level name become dashes: coinspectrum.deviant.bch
// -> coinspectrum-deviant.bch.navigate.st
//
// Honest disclosure (convenience tier, not the trustless one): the operator can
// see which .bch sites you request. No logs — but that's trust. Install the
// resolver or Theseus for trustless resolution; use Tor for IP privacy.
import http from "http" ;
gateway+Theseus: pin BNS ip-record fetch to on-chain tls fingerprint
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).
2026-08-16 20:28:58 +02:00
import https from "https" ;
import tls from "tls" ;
2026-07-29 13:54:34 +02:00
import { AwsClient } from "aws4fetch" ;
import WebSocket from "ws" ;
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
// resolver-web.js lives at ../lib/, not alongside this file — the previous
// `./resolver-web.js` import was broken (file was moved into lib/ but this
// import wasn't updated). Fix while touching the file.
2026-09-06 22:51:54 +02:00
import { resolveName , buildIndex , normalizeName , connectAny , CHIPNET _ELECTRUM } from "./resolver-web.js" ;
2026-08-31 16:23:43 +02:00
import { pickRecord , isSubdomainOf } from "./record-picker.js" ;
2026-07-29 13:54:34 +02:00
// Full-index cache (rebuilt every 30s) for fast availability lookups.
let _idx = { at : 0 , index : null } ;
async function getIndex ( ) {
if ( ! _idx . index || Date . now ( ) - _idx . at > 30000 ) {
_idx = { at : Date . now ( ) , index : await buildIndex ( { WebSocket } ) } ;
}
return _idx . index ;
}
const MOUNT = process . env . BNS _MOUNT || "/bns" ;
const PORT = Number ( process . env . BNS _PORT || 8700 ) ;
2026-09-06 19:58:58 +02:00
// Names hidden from /api/search/ results — direct navigation still works.
const HIDDEN _FROM _SEARCH = new Set ( [ "syskypo.de" ] ) ;
2026-07-29 13:54:34 +02:00
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 ( {
accessKeyId : process . env . AK , secretAccessKey : process . env . SK ,
service : "s3" , region : "us-east-1" ,
} ) ;
const cache = new Map ( ) ;
async function resolve ( host ) {
const c = cache . get ( host ) ;
if ( c && Date . now ( ) - c . at < 30000 ) return c . entry ;
const entry = await resolveName ( host , { WebSocket } ) ; // resolveName normalizes any <sub>.<label>.<tld>
cache . set ( host , { entry , at : Date . now ( ) } ) ;
return entry ;
}
// subfolder = labels before the registered "<label>.<tld>" (drop tld, drop label)
const subFolder = ( host ) => {
const l = host . split ( "." ) ; l . pop ( ) ; l . pop ( ) ; return l . reverse ( ) . join ( "/" ) ;
} ;
const MIME = { html : "text/html; charset=utf-8" , css : "text/css" , js : "text/javascript" ,
json : "application/json" , png : "image/png" , jpg : "image/jpeg" , jpeg : "image/jpeg" ,
gif : "image/gif" , svg : "image/svg+xml" , ico : "image/x-icon" , webp : "image/webp" ,
woff2 : "font/woff2" , woff : "font/woff" , wasm : "application/wasm" , txt : "text/plain" } ;
const guess = ( p ) => MIME [ p . split ( "." ) . pop ( ) ? . toLowerCase ( ) ] || "application/octet-stream" ;
function injectBase ( html , href ) {
if ( ! href || /<base\s/i . test ( html ) ) return html ;
if ( /<head[^>]*>/i . test ( html ) ) return html . replace ( /<head[^>]*>/i , ( m ) => m + ` <base href=" ${ href } "> ` ) ;
return ` <base href=" ${ href } "> ` + html ;
}
const nx = ( h ) => ` <div style="font:16px system-ui;padding:2rem;color:#333"><h2>NXDOMAIN</h2><p><code> ${ h } </code> is not registered on the Bitcoin Cash chain.</p></div> ` ;
gateway+Theseus: pin BNS ip-record fetch to on-chain tls fingerprint
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).
2026-08-16 20:28:58 +02:00
// BNS `ip`-record fetch. The default `fetch` fails here for two reasons:
// 1. It follows the site's HTTP→HTTPS 301 into `https://<name>.<tld>/`, which
// isn't in ICANN DNS → "fetch failed".
// 2. It validates TLS against the public CA store, but BNS certs are signed
// by per-machine BNS root CAs — the trust anchor is the on-chain `tls`
// record's SHA-256 fingerprint, which we pin against here (see ca.js for
// the trust model). This matches what Theseus does in-process.
// Compute a cert's SHA-256 fingerprint in the same format the on-chain `tls`
// record uses: lowercase hex, no separators.
function certFingerprint ( cert ) {
const fp = cert && cert . fingerprint256 ;
return fp ? fp . toLowerCase ( ) . replace ( /:/g , "" ) : "" ;
}
// HTTPS GET pinned to an expected on-chain fingerprint. Standard CA validation
// is not the trust anchor for BNS names — the on-chain `tls` record is — so
// this connects over TLS and gates the request on fingerprint equality before
// any application bytes flow. A mismatch destroys the socket and rejects.
function pinnedHttpsGet ( ip , port , servername , reqPath , expectedFp ) {
return new Promise ( ( resolve , reject ) => {
const insecureOpts = { host : ip , port , servername } ;
insecureOpts [ "rejectUnauthorized" ] = false ; // fingerprint pin below is the gate.
const socket = tls . connect ( insecureOpts , ( ) => {
const gotFp = certFingerprint ( socket . getPeerCertificate ( false ) ) ;
if ( gotFp !== expectedFp ) {
socket . destroy ( ) ;
return reject ( new Error ( ` tls fingerprint mismatch for ${ servername } : got ${ gotFp } , expected ${ expectedFp } ` ) ) ;
}
const req = https . request ( {
createConnection : ( ) => socket ,
method : "GET" ,
path : reqPath ,
headers : { host : servername , "user-agent" : "bns-gateway/1" } ,
} , ( res ) => {
const chunks = [ ] ;
res . on ( "data" , ( c ) => chunks . push ( c ) ) ;
res . on ( "end" , ( ) => resolve ( { status : res . statusCode , headers : res . headers , buffer : Buffer . concat ( chunks ) } ) ) ;
} ) ;
req . on ( "error" , reject ) ;
req . end ( ) ;
} ) ;
socket . setTimeout ( 15000 , ( ) => { socket . destroy ( new Error ( "tls request timeout" ) ) ; } ) ;
socket . on ( "error" , reject ) ;
} ) ;
}
// Fallback path — plain HTTP by IP with Host header. Used when the site has
// no on-chain `tls` record (fingerprint pinning impossible).
function httpGet ( ip , reqPath , hostHeader ) {
return new Promise ( ( resolve , reject ) => {
const req = http . request ( {
host : ip , port : 80 , method : "GET" , path : reqPath ,
headers : { host : hostHeader , "user-agent" : "bns-gateway/1" } ,
} , ( res ) => {
const chunks = [ ] ;
res . on ( "data" , ( c ) => chunks . push ( c ) ) ;
res . on ( "end" , ( ) => resolve ( { status : res . statusCode , headers : res . headers , buffer : Buffer . concat ( chunks ) } ) ) ;
} ) ;
req . on ( "error" , reject ) ;
req . end ( ) ;
} ) ;
}
// Compose: try pinned HTTPS if the on-chain `tls` fingerprint is available,
// otherwise plain HTTP by IP. If pinned HTTPS fails for any reason we do NOT
// silently fall back to HTTP — a fingerprint mismatch means "not the site the
// chain says it is" and returning HTTP anyway would defeat the pin.
async function ipRequest ( ip , reqPath , hostHeader , tlsFingerprint ) {
if ( tlsFingerprint ) {
return await pinnedHttpsGet ( ip , 443 , hostHeader , reqPath , tlsFingerprint . toLowerCase ( ) ) ;
}
return await httpGet ( ip , reqPath , hostHeader ) ;
}
2026-09-06 22:51:54 +02:00
// Reverse-proxy to a full upstream URL — the `p` record handler. Unlike
// ipRequest (raw IP + pinned cert from the chain), this uses the upstream's
// own DNS and public CA cert. The Host header sent upstream is the upstream's
// hostname, not the BNS name — so vhost-based upstreams answer with their own
// vhost, which is the whole point (mirror behaviour). Path is upstream's own
// prefix plus the caller's reqPath, so `{p:"https://api.example.com/v1"}` +
// request "/x" hits `https://api.example.com/v1/x`.
function proxyToUrl ( upstreamUrl , reqPath ) {
const url = new URL ( upstreamUrl ) ;
const client = url . protocol === "https:" ? https : http ;
const port = url . port || ( url . protocol === "https:" ? 443 : 80 ) ;
const prefix = url . pathname === "/" ? "" : url . pathname . replace ( /\/$/ , "" ) ;
const upstreamPath = prefix + reqPath ;
return new Promise ( ( resolve , reject ) => {
const req = client . request ( {
host : url . hostname , port , method : "GET" , path : upstreamPath ,
headers : { host : url . host , "user-agent" : "bns-gateway/1" } ,
} , ( up ) => {
const chunks = [ ] ;
up . on ( "data" , ( c ) => chunks . push ( c ) ) ;
up . on ( "end" , ( ) => resolve ( { status : up . statusCode , headers : up . headers , buffer : Buffer . concat ( chunks ) } ) ) ;
} ) ;
req . setTimeout ( 15000 , ( ) => req . destroy ( new Error ( "proxy timeout" ) ) ) ;
req . on ( "error" , reject ) ;
req . end ( ) ;
} ) ;
}
2026-07-29 13:54:34 +02:00
// Serve a resolved name. baseHref = null for subdomain form (own origin).
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
//
// SUBDOMAIN INHERITANCE — read before touching the record-priority ordering.
// A subdomain like `checkers.game.x` shares its parent's on-chain entry
// (`normalizeName` collapses it to `game.x`), so we serve it from the parent's
// records. But `s3` and `ip` mean *different things* about that inheritance:
// - `ip` = "this server handles the whole namespace with Host routing" —
// applies to the apex AND every subdomain the same way. Sending
// `Host: checkers.game.x` lets the parent's nginx serve the subdomain.
// - `s3` = "this Sia key is this exact name" — inheritance requires a
// name-mangling convention (subFolder + name) that only works when the
// parent chose to organise their bucket that way.
// So for subdomains, `ip` is the unambiguous intent and takes priority. For the
// apex, current priority stands (h → s3 → ip → u). This does not break
// Sia-per-subdomain owners: if they omit `ip` from the parent, the fallback
// still tries `s3` first as before.
2026-07-29 13:54:34 +02:00
async function serve ( res , name , reqPath , baseHref ) {
let entry ;
try { entry = await resolve ( name ) ; } catch ( e ) { res . writeHead ( 502 ) ; return res . end ( "resolve error: " + e . message ) ; }
if ( ! entry ) { res . writeHead ( 404 , { "content-type" : "text/html" } ) ; return res . end ( nx ( name ) ) ; }
const r = entry . records ;
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
// `pickRecord` is the single source of truth for record priority; keep this
// call and the inline copy in TheseusNavigator/main.js:serveBns in step.
const pick = pickRecord ( r , { isSubdomain : isSubdomainOf ( name , entry . name ) } ) ;
2026-07-29 13:54:34 +02:00
try {
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
switch ( pick ) {
case "h" :
if ( reqPath !== "/" ) { res . writeHead ( 404 ) ; return res . end ( "not found" ) ; }
res . writeHead ( 200 , { "content-type" : "text/html; charset=utf-8" } ) ;
return res . end ( injectBase ( r . h , baseHref ) ) ;
case "s3" : {
const sub = subFolder ( name ) ;
let base = r . s3 ; if ( base . endsWith ( "/" ) && sub ) base += sub + "/" ;
2026-08-29 13:27:42 +02:00
// Auto-index directory paths. S3 has no dir listing, so `/tools/`
// would 404 (NoSuchKey) without this — matches how nginx handles
// `try_files … /index.html` and how Ariadne's bnsd.js rewrites the
// same URLs client-side.
const rel = reqPath === "/" ? "index.html"
: reqPath . endsWith ( "/" ) ? reqPath . slice ( 1 ) + "index.html"
: reqPath . slice ( 1 ) ;
const key = base . endsWith ( "/" ) ? base + rel : base ;
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
const up = await aws . fetch ( ` ${ S3 _ENDPOINT } / ${ key } ` ) ;
const buf = Buffer . from ( await up . arrayBuffer ( ) ) ;
let ct = up . headers . get ( "content-type" ) ; if ( ! ct || ct === "application/octet-stream" ) ct = guess ( key ) ;
if ( ct . includes ( "text/html" ) ) { res . writeHead ( up . status , { "content-type" : ct } ) ; return res . end ( injectBase ( buf . toString ( "utf8" ) , baseHref ) ) ; }
res . writeHead ( up . status , { "content-type" : ct } ) ; return res . end ( buf ) ;
}
case "ip" : {
gateway+Theseus: pin BNS ip-record fetch to on-chain tls fingerprint
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).
2026-08-16 20:28:58 +02:00
const up = await ipRequest ( r . ip , reqPath , name , r . tls ) ;
const buf = up . buffer ;
2026-08-16 11:37:24 +02:00
let ct = up . headers [ "content-type" ] || guess ( reqPath ) ;
2026-08-31 16:23:43 +02:00
// Forward the upstream's other response headers so redirects, cookies,
// caching directives, etc. actually work end-to-end. Drop hop-by-hop
// headers (RFC 7230 §6.1) plus Content-Length (we may rewrite HTML).
const HOP _BY _HOP = new Set ( [
"connection" , "keep-alive" , "transfer-encoding" ,
"proxy-authenticate" , "proxy-authorization" ,
"te" , "trailer" , "upgrade" , "content-length" ,
] ) ;
const outHeaders = { "content-type" : ct } ;
for ( const [ k , v ] of Object . entries ( up . headers ) ) {
const lk = k . toLowerCase ( ) ;
if ( HOP _BY _HOP . has ( lk ) ) continue ;
if ( lk === "content-type" ) continue ;
outHeaders [ k ] = v ;
}
if ( ct . includes ( "text/html" ) ) { res . writeHead ( up . status , outHeaders ) ; return res . end ( injectBase ( buf . toString ( "utf8" ) , baseHref ) ) ; }
res . writeHead ( up . status , outHeaders ) ; return res . end ( buf ) ;
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
}
2026-09-06 22:51:54 +02:00
case "p" : {
const up = await proxyToUrl ( r . p , reqPath ) ;
const buf = up . buffer ;
let ct = up . headers [ "content-type" ] || guess ( reqPath ) ;
const HOP _BY _HOP = new Set ( [
"connection" , "keep-alive" , "transfer-encoding" ,
"proxy-authenticate" , "proxy-authorization" ,
"te" , "trailer" , "upgrade" , "content-length" ,
] ) ;
const outHeaders = { "content-type" : ct } ;
for ( const [ k , v ] of Object . entries ( up . headers ) ) {
const lk = k . toLowerCase ( ) ;
if ( HOP _BY _HOP . has ( lk ) ) continue ;
if ( lk === "content-type" ) continue ;
outHeaders [ k ] = v ;
}
if ( ct . includes ( "text/html" ) ) { res . writeHead ( up . status , outHeaders ) ; return res . end ( injectBase ( buf . toString ( "utf8" ) , baseHref ) ) ; }
res . writeHead ( up . status , outHeaders ) ; return res . end ( buf ) ;
}
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
case "u" :
res . writeHead ( 302 , { location : r . u } ) ; return res . end ( ) ;
default :
res . writeHead ( 200 , { "content-type" : "application/json" } ) ; return res . end ( JSON . stringify ( entry , null , 2 ) ) ;
2026-07-29 13:54:34 +02:00
}
} catch ( e ) { res . writeHead ( 502 ) ; res . end ( "gateway error: " + e . message ) ; }
}
2026-07-30 21:21:01 +02:00
// Native BNS TLDs — never overlap ICANN, so hijacking them here is safe.
// Dual TLDs (.com, .de, .dev) always fall through to search, so a bare
// `github.com` in the URL bar keeps going to the real GitHub, not to a
// BNS registration that happens to share the name.
const NATIVE _TLDS = new Set ( [ "bch" , "p2p" , "bit" , "nav" ] ) ;
// Universal "set-as-default-search-engine" shim. Users add
// https://navigate.st/search?q=%s
// as their browser's default search engine; typing `silentmode.bch` in the URL
// bar (which Chromium/Firefox otherwise send to Google as a search query
// because .bch isn't in ICANN's known-TLD list) arrives here. If q looks like
// a native BNS name we 302 to the relay (works in every browser, no local
// daemon needed); otherwise forward to DuckDuckGo so ordinary search still
// works. Even an unregistered native-TLD query goes to the relay so the user
// gets a clear NXDOMAIN page instead of a confusing search detour.
async function handleSearch ( url , res ) {
const q = ( url . searchParams . get ( "q" ) || "" ) . trim ( ) . toLowerCase ( ) ;
const bare = q . replace ( /^[a-z]+:\/\// , "" ) . split ( "/" ) [ 0 ] ;
const m = bare . match ( /^([a-z0-9-]+)\.([a-z0-9-]+)$/ ) ;
let target ;
if ( m && NATIVE _TLDS . has ( m [ 2 ] ) ) {
target = ` https://navigate.st/bns/ ${ m [ 1 ] } . ${ m [ 2 ] } / ` ;
} else {
target = ` https://duckduckgo.com/?q= ${ encodeURIComponent ( q ) } ` ;
}
res . writeHead ( 302 , { location : target } ) ;
res . end ( ) ;
}
2026-07-29 13:54:34 +02:00
const server = http . createServer ( async ( req , res ) => {
try {
const hostHeader = ( req . headers . host || "" ) . split ( ":" ) [ 0 ] . toLowerCase ( ) ;
2026-07-30 21:21:01 +02:00
// --- smart search endpoint (default-search-engine shim) ---
if ( req . url . startsWith ( "/search" ) ) {
const u = new URL ( req . url , "http://x" ) ;
return handleSearch ( u , res ) ;
}
2026-09-07 02:29:02 +02:00
// --- icon upload endpoint (Deviant Potidaea Katalogos DVMB icons) ---
// POST /api/icon-upload with a raw image body →
// { sha256, path: "s3://bns/potidaea/icons/<sha256>.<ext>",
// url: "https://navigate.st/bns/potidaea.asm/icons/<sha256>.<ext>" }
//
// Content-addressed: the storage key IS the SHA-256 of the bytes, so
// • re-uploads of the same content overwrite themselves harmlessly
// • per-user quota isn't necessary (a spammer just re-writes their
// own keys), and CORS is safe as * because we never touch state
// • the on-chain iconSha256 field ties the entry NFT to specific
// bytes, so a rogue upload can't hijack another asset's icon
//
// Guards: 128 KB size cap, image/* content-type whitelist. Any
// authorization checks would break the mockup's chipnet-test posture;
// spam is inherently rate-limited by disk write speed on the shared
// Sia bucket. If misuse becomes a problem, add a per-IP rate-limit
// here rather than authentication (the mockup has none by design).
if ( req . url === "/api/icon-upload" ) {
// OPTIONS preflight — CORS
if ( req . method === "OPTIONS" ) {
res . writeHead ( 204 , {
"access-control-allow-origin" : "*" ,
"access-control-allow-methods" : "POST, OPTIONS" ,
"access-control-allow-headers" : "content-type" ,
"access-control-max-age" : "86400" ,
} ) ;
return res . end ( ) ;
}
res . setHeader ( "access-control-allow-origin" , "*" ) ;
if ( req . method !== "POST" ) {
res . writeHead ( 405 , { "content-type" : "application/json" } ) ;
return res . end ( JSON . stringify ( { error : "POST only" } ) ) ;
}
const CT _TO _EXT = {
"image/svg+xml" : "svg" , "image/png" : "png" , "image/jpeg" : "jpg" ,
"image/gif" : "gif" , "image/webp" : "webp" , "image/x-icon" : "ico" ,
} ;
const ct = ( req . headers [ "content-type" ] || "" ) . split ( ";" ) [ 0 ] . trim ( ) . toLowerCase ( ) ;
const ext = CT _TO _EXT [ ct ] ;
if ( ! ext ) {
res . writeHead ( 415 , { "content-type" : "application/json" } ) ;
return res . end ( JSON . stringify ( { error : "unsupported content-type; allowed: " + Object . keys ( CT _TO _EXT ) . join ( ", " ) } ) ) ;
}
const chunks = [ ] ;
let total = 0 ;
const LIMIT = 128 * 1024 ;
let aborted = false ;
req . on ( "data" , ( c ) => {
total += c . length ;
if ( total > LIMIT ) {
aborted = true ;
req . destroy ( ) ;
try { res . writeHead ( 413 , { "content-type" : "application/json" } ) ; res . end ( JSON . stringify ( { error : "body too large (>128 KB)" } ) ) ; } catch { }
return ;
}
chunks . push ( c ) ;
} ) ;
req . on ( "end" , async ( ) => {
if ( aborted ) return ;
try {
const bytes = Buffer . concat ( chunks ) ;
const { createHash } = await import ( "node:crypto" ) ;
const sha256 = createHash ( "sha256" ) . update ( bytes ) . digest ( "hex" ) ;
const key = ` bns/potidaea/icons/ ${ sha256 } . ${ ext } ` ;
const putUrl = ` ${ S3 _ENDPOINT } / ${ key } ` ;
const r = await aws . fetch ( putUrl , { method : "PUT" , body : bytes , headers : { "content-type" : ct } } ) ;
if ( ! r . ok ) {
const detail = await r . text ( ) . catch ( ( ) => "" ) ;
res . writeHead ( 502 , { "content-type" : "application/json" } ) ;
return res . end ( JSON . stringify ( { error : "Sia PUT failed" , status : r . status , detail : detail . slice ( 0 , 200 ) } ) ) ;
}
const publicUrl = ` https://navigate.st/bns/potidaea.asm/icons/ ${ sha256 } . ${ ext } ` ;
res . writeHead ( 200 , { "content-type" : "application/json" } ) ;
res . end ( JSON . stringify ( { sha256 , ext , path : ` s3:// ${ key } ` , url : publicUrl , bytes : bytes . length } ) ) ;
} catch ( e ) {
res . writeHead ( 500 , { "content-type" : "application/json" } ) ;
res . end ( JSON . stringify ( { error : String ( e ? . message || e ) } ) ) ;
}
} ) ;
req . on ( "error" , ( ) => { /* swallow */ } ) ;
return ;
}
2026-08-31 16:23:43 +02:00
// --- registry API: advertised TLDs (mirrors on-chain `tlds.bch` "tlds" record) ---
// GET /api/tlds -> { source, height, updated, tlds: [{ tld, registered, in_advertised }], advertised: [...] }
// The list a name-selling site scans to know which suffixes it may sell.
// `advertised` is exactly what the chain says (space-split); `tlds` is the
// observed union of every TLD that appears on any registered name plus every
// TLD in the advertised list — so a shop sees "sm" (advertised) even if no
// .sm has been registered yet, and "silentmode" (registered) even if it
// isn't advertised.
2026-08-29 16:43:27 +02:00
if ( req . url === "/api/tlds" || req . url . startsWith ( "/api/tlds?" ) ) {
res . setHeader ( "access-control-allow-origin" , "*" ) ;
res . setHeader ( "content-type" , "application/json" ) ;
try {
2026-08-31 16:23:43 +02:00
const idx = await getIndex ( ) ;
2026-08-29 16:43:27 +02:00
const observed = new Map ( ) ; // tld -> count of names
for ( const key of idx . keys ( ) ) {
const dot = key . indexOf ( "." ) ;
const tld = dot < 0 ? "bch" : key . slice ( dot + 1 ) ;
observed . set ( tld , ( observed . get ( tld ) || 0 ) + 1 ) ;
}
2026-08-31 16:23:43 +02:00
const anchor = idx . get ( "tlds.bch" ) ;
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 ) ,
2026-08-29 16:43:27 +02:00
} ) ) ;
res . writeHead ( 200 ) ;
res . end ( JSON . stringify ( {
2026-08-31 16:23:43 +02:00
source : "tlds.bch" ,
height : anchor ? . height ? ? null ,
updated _txid : anchor ? . updatedTxid ? ? anchor ? . txid ? ? null ,
advertised ,
tlds ,
2026-08-29 16:43:27 +02:00
} , null , 2 ) ) ;
} catch ( e ) {
res . writeHead ( 500 ) ; res . end ( JSON . stringify ( { error : e . message } ) ) ;
}
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
// offline. Refreshed at most every 30s server-side (same cache as /api/*).
if ( req . url === "/api/registry" || req . url . startsWith ( "/api/registry?" ) ) {
res . setHeader ( "access-control-allow-origin" , "*" ) ;
res . setHeader ( "content-type" , "application/json" ) ;
try {
const idx = await getIndex ( ) ;
const names = [ ] ;
let heightHint = 0 ;
for ( const [ key , entry ] of idx ) {
const dot = key . indexOf ( "." ) ;
const displayName = dot < 0 ? key + ".bch" : key ;
const tld = dot < 0 ? "bch" : key . slice ( dot + 1 ) ;
if ( entry ? . height && entry . height > heightHint ) heightHint = entry . height ;
names . push ( {
name : displayName ,
tld ,
records : entry ? . records ? ? null ,
category : entry ? . category ? ? null ,
txid : entry ? . txid ? ? null ,
height : entry ? . height ? ? null ,
updated _txid : entry ? . updatedTxid ? ? null ,
} ) ;
}
names . sort ( ( a , b ) => a . name . localeCompare ( b . name ) ) ;
res . writeHead ( 200 ) ;
res . end ( JSON . stringify ( { count : names . length , height _hint : heightHint , names } , null , 2 ) ) ;
} catch ( e ) {
res . writeHead ( 500 ) ; res . end ( JSON . stringify ( { error : e . message } ) ) ;
}
return ;
}
2026-09-06 22:51:54 +02:00
// GET /api/holdings/<scripthash> -> { scripthash, count, names: [...] }
// POST /api/holdings body: {"scripthashes":[…]} -> { count, names: [...] }
//
// Server-side wallet-holdings lookup for the sirius.x portal. bns-indexer
// only serves the beacon; arbitrary-address listunspent needs a full
// electrum, which many browsers cannot reach on ports 50004/50011/62002
// (firewalls, mobile carriers). The gateway can reach them, so it queries
// and returns the cross-joined result: the client POSTs a scripthash list
// and gets back the BCNR names each hash currently holds an NFT for.
//
// Takes scripthash (not cashaddr) to keep the gateway libauth-free; the
// client's wallet code already knows the scripthash for each of its
// watched addresses.
if ( req . url === "/api/holdings" || req . url . startsWith ( "/api/holdings/" ) || req . url . startsWith ( "/api/holdings?" ) ) {
res . setHeader ( "access-control-allow-origin" , "*" ) ;
res . setHeader ( "content-type" , "application/json" ) ;
let scripthashes = [ ] ;
if ( req . method === "POST" ) {
try {
const chunks = [ ] ;
for await ( const c of req ) chunks . push ( c ) ;
const body = JSON . parse ( Buffer . concat ( chunks ) . toString ( "utf8" ) || "{}" ) ;
scripthashes = Array . isArray ( body . scripthashes ) ? body . scripthashes : [ ] ;
} catch { res . writeHead ( 400 ) ; res . end ( JSON . stringify ( { error : "invalid JSON body" } ) ) ; return ; }
} else {
const one = req . url . slice ( "/api/holdings/" . length ) . split ( "?" ) [ 0 ] ;
if ( one ) scripthashes = [ one ] ;
}
scripthashes = scripthashes . filter ( ( s ) => typeof s === "string" && /^[0-9a-f]{64}$/i . test ( s ) ) ;
if ( ! scripthashes . length ) {
res . writeHead ( 400 ) ;
res . end ( JSON . stringify ( { error : "no valid scripthashes (need 64-char hex, POST as {scripthashes:[…]} or GET /api/holdings/<hex>)" } ) ) ;
return ;
}
if ( scripthashes . length > 64 ) {
res . writeHead ( 400 ) ; res . end ( JSON . stringify ( { error : "at most 64 scripthashes per request" } ) ) ; return ;
}
// Skip beacon-only indexers (silentmode.st/electrum on 443, our own
// bns-indexer on :50011) — they don't implement listunspent.
const BNS _INDEXER _URLS = /silentmode\.st\/electrum|coinspectrum\.duckdns\.org:50011/ ;
const candidates = CHIPNET _ELECTRUM
. map ( ( s ) => ( typeof s === "string" ? { url : s } : s ) )
. filter ( ( s ) => ! BNS _INDEXER _URLS . test ( s . url ) ) ;
let el , lastErr ;
for ( const s of candidates ) {
try { el = await connectAny ( [ s ] , WebSocket ) ; break ; }
catch ( e ) { lastErr = e ; }
}
if ( ! el ) {
res . writeHead ( 503 ) ;
res . end ( JSON . stringify ( { error : "no full electrum reachable" , detail : String ( lastErr ? . message || lastErr ) } ) ) ;
return ;
}
try {
const utxoLists = await Promise . all ( scripthashes . map ( ( sh ) =>
el . call ( "blockchain.scripthash.listunspent" , [ sh , "include_tokens" ] ) . catch ( ( ) => [ ] )
) ) ;
const categories = new Set ( ) ;
for ( const list of utxoLists ) {
for ( const u of list ) if ( u . token _data ? . nft ) categories . add ( u . token _data . category ) ;
}
const idx = await getIndex ( ) ;
const names = [ ] ;
for ( const [ key , entry ] of idx ) {
if ( ! categories . has ( entry . category ) ) continue ;
const dot = key . indexOf ( "." ) ;
const displayName = dot < 0 ? key + ".bch" : key ;
const tld = dot < 0 ? "bch" : key . slice ( dot + 1 ) ;
names . push ( {
name : displayName ,
tld ,
category : entry . category ,
records : entry . records ? ? null ,
txid : entry . txid ? ? null ,
height : entry . height ? ? null ,
updated _txid : entry . updatedTxid ? ? null ,
} ) ;
}
names . sort ( ( a , b ) => a . name . localeCompare ( b . name ) ) ;
const body = req . method === "POST"
? { count : names . length , scripthashes _queried : scripthashes . length , names }
: { scripthash : scripthashes [ 0 ] , count : names . length , names } ;
res . writeHead ( 200 ) ;
res . end ( JSON . stringify ( body , null , 2 ) ) ;
} catch ( e ) {
res . writeHead ( 500 ) ;
res . end ( JSON . stringify ( { error : String ( e . message || e ) } ) ) ;
} finally {
try { el . close ( ) ; } catch { }
}
return ;
}
2026-07-29 13:54:34 +02:00
// --- registrar API: availability of a name ---
// GET /api/name/<name> -> { name, tld, registered, records? }
if ( req . url . startsWith ( "/api/name/" ) ) {
res . setHeader ( "access-control-allow-origin" , "*" ) ;
res . setHeader ( "content-type" , "application/json" ) ;
const raw = decodeURIComponent ( req . url . slice ( "/api/name/" . length ) . split ( "?" ) [ 0 ] ) ;
try {
const key = normalizeName ( raw ) ; // "<label>.<tld>"
const tld = key . split ( "." ) . pop ( ) ;
const entry = ( await getIndex ( ) ) . get ( key ) ;
res . writeHead ( 200 ) ;
res . end ( JSON . stringify ( {
name : key , tld , registered : ! ! entry ,
records : entry ? . records ? ? null , category : entry ? . category ? ? null ,
} ) ) ;
} catch ( e ) {
res . writeHead ( 400 ) ; res . end ( JSON . stringify ( { error : e . message } ) ) ;
}
return ;
}
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.
2026-08-20 00:28:00 +02:00
// --- 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 ;
}
2026-09-06 19:58:58 +02:00
// --- unified search: label + content + fuzzy fallback ---
// GET /api/search/<q> -> { query, count, results: [{ name, tld, matchType, score, snippet, dist?, records }] }
// Scores: label-exact (100) > label-substring (70) > h-record content (50) > fuzzy (40|25|10 for dist 1|2|3).
// Fuzzy is bounded Levenshtein against the label — catches typos like "hlelo" → "hello.bch"
// without polluting exact-match results (fuzzy hits only rank below every other match type).
navigate.st: Theseus-home-style landing — search + quick links + install CTA
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.
2026-08-20 20:44:09 +02:00
// Content search covers on-chain inline HTML only; Sia/ip sites await Helios v1.
// Same cached index as /api/labels/ and /api/name/ — no extra fetch cost on the hot path.
if ( req . url . startsWith ( "/api/search/" ) ) {
res . setHeader ( "access-control-allow-origin" , "*" ) ;
res . setHeader ( "content-type" , "application/json" ) ;
const q = decodeURIComponent ( req . url . slice ( "/api/search/" . length ) . split ( "?" ) [ 0 ] ) . toLowerCase ( ) . trim ( ) ;
if ( ! q || q . length < 2 || q . length > 64 ) {
res . writeHead ( 400 ) ;
res . end ( JSON . stringify ( { error : "query must be 2-64 chars" } ) ) ;
return ;
}
2026-09-06 19:58:58 +02:00
// Bounded Levenshtein: early-exits once the running minimum row-value exceeds `max`.
// Returns `max + 1` for "too far to matter" — the caller ignores anything > max.
const boundedLev = ( a , b , max ) => {
if ( Math . abs ( a . length - b . length ) > max ) return max + 1 ;
if ( a === b ) return 0 ;
// Ensure a is the shorter for less memory.
if ( a . length > b . length ) { const t = a ; a = b ; b = t ; }
let prev = new Array ( a . length + 1 ) ;
for ( let i = 0 ; i <= a . length ; i ++ ) prev [ i ] = i ;
for ( let j = 1 ; j <= b . length ; j ++ ) {
const curr = new Array ( a . length + 1 ) ;
curr [ 0 ] = j ;
let rowMin = curr [ 0 ] ;
const bj = b . charCodeAt ( j - 1 ) ;
for ( let i = 1 ; i <= a . length ; i ++ ) {
const cost = a . charCodeAt ( i - 1 ) === bj ? 0 : 1 ;
curr [ i ] = Math . min ( prev [ i ] + 1 , curr [ i - 1 ] + 1 , prev [ i - 1 ] + cost ) ;
if ( curr [ i ] < rowMin ) rowMin = curr [ i ] ;
}
if ( rowMin > max ) return max + 1 ; // no path can recover below the threshold
prev = curr ;
}
return prev [ a . length ] ;
} ;
// Typo budget scales with query length so short queries don't match everything.
const maxDist = q . length <= 3 ? 1 : q . length <= 6 ? 2 : 3 ;
navigate.st: Theseus-home-style landing — search + quick links + install CTA
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.
2026-08-20 20:44:09 +02:00
try {
const idx = await getIndex ( ) ;
const results = [ ] ;
for ( const [ key , entry ] of idx ) {
const dot = key . indexOf ( "." ) ;
const label = dot < 0 ? key : key . slice ( 0 , dot ) ;
const tld = dot < 0 ? "bch" : key . slice ( dot + 1 ) ;
const displayName = dot < 0 ? key + ".bch" : key ;
const records = entry ? . records ? ? { } ;
2026-09-06 19:58:58 +02:00
// Names on chain but hidden from free-text search — direct lookup
// (/api/name/, /bns/…) still works so demos stay reachable, they
// just don't clutter the suggestion list. syskypo.de is a mirror
// of poutakidis-content, kept live but redundant in search.
if ( HIDDEN _FROM _SEARCH . has ( displayName ) ) continue ;
navigate.st: Theseus-home-style landing — search + quick links + install CTA
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.
2026-08-20 20:44:09 +02:00
2026-09-06 19:58:58 +02:00
let matchType = null , score = 0 , snippet = null , dist ;
navigate.st: Theseus-home-style landing — search + quick links + install CTA
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.
2026-08-20 20:44:09 +02:00
if ( label === q ) { matchType = "label-exact" ; score = 100 ; }
else if ( label . includes ( q ) ) { matchType = "label-substring" ; score = 70 ; }
// Content search over inline HTML (h record). Strip tags first so a
// query like "poutakidis" doesn't accidentally match "class=poutakidis".
if ( typeof records . h === "string" && records . h ) {
const text = records . h . replace ( /<[^>]+>/g , " " ) . replace ( /\s+/g , " " ) . trim ( ) ;
const i = text . toLowerCase ( ) . indexOf ( q ) ;
if ( i >= 0 ) {
if ( ! matchType ) { matchType = "content" ; score = 50 ; }
const start = Math . max ( 0 , i - 40 ) ;
const end = Math . min ( text . length , i + q . length + 60 ) ;
snippet = ( start > 0 ? "…" : "" ) + text . slice ( start , end ) + ( end < text . length ? "…" : "" ) ;
}
}
2026-09-06 19:58:58 +02:00
// Fuzzy fallback — only when nothing above matched, only against the label.
// Score decays sharply so a real substring match always outranks a 1-edit near-miss.
if ( ! matchType ) {
const d = boundedLev ( q , label , maxDist ) ;
if ( d <= maxDist ) {
matchType = "fuzzy" ;
dist = d ;
score = d === 1 ? 40 : d === 2 ? 25 : 10 ;
}
}
navigate.st: Theseus-home-style landing — search + quick links + install CTA
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.
2026-08-20 20:44:09 +02:00
if ( matchType ) {
2026-09-06 19:58:58 +02:00
const row = { name : displayName , tld , matchType , score , snippet ,
records : entry ? . records ? ? null , category : entry ? . category ? ? null } ;
if ( dist !== undefined ) row . dist = dist ;
results . push ( row ) ;
navigate.st: Theseus-home-style landing — search + quick links + install CTA
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.
2026-08-20 20:44:09 +02:00
}
}
results . sort ( ( a , b ) => b . score - a . score || a . name . localeCompare ( b . name ) ) ;
const capped = results . slice ( 0 , 50 ) ;
res . writeHead ( 200 ) ;
res . end ( JSON . stringify ( { query : q , count : capped . length , truncated : results . length > 50 , results : capped } ) ) ;
} catch ( e ) {
res . writeHead ( 500 ) ; res . end ( JSON . stringify ( { error : e . message } ) ) ;
}
return ;
}
2026-07-29 13:54:34 +02:00
// --- subdomain form: <flattened>.bch.<SUB_BASE> ---
if ( hostHeader . endsWith ( "." + SUB _BASE ) ) {
const inner = hostHeader . slice ( 0 , - ( "." + SUB _BASE ) . length ) ; // "<flattened>.bch" or "<flattened>"
const flat = inner . endsWith ( ".bch" ) ? inner . slice ( 0 , - 4 ) : inner ;
if ( flat ) {
const name = flat . replace ( /-/g , "." ) + ".bch" ; // dashes back to dots
return serve ( res , name , req . url || "/" , null ) ; // own origin: no <base>
}
}
// bare apex of SUB_BASE (e.g. bch.navigate.st) -> home
if ( hostHeader === SUB _BASE ) { res . writeHead ( 200 , { "content-type" : "text/html" } ) ; return res . end ( home ( ) ) ; }
// --- path form: /bns/<name>.bch/... ---
let p = req . url ;
if ( p === MOUNT || p === MOUNT + "/" ) { res . writeHead ( 200 , { "content-type" : "text/html" } ) ; return res . end ( home ( ) ) ; }
if ( ! p . startsWith ( MOUNT + "/" ) ) { res . writeHead ( 404 ) ; return res . end ( "bns gateway" ) ; }
p = p . slice ( MOUNT . length + 1 ) ;
const m = p . match ( /^([a-z0-9-]+(?:\.[a-z0-9-]+)+)(\/.*)?$/i ) ; // <name>.<tld>, any TLD
if ( ! m ) { res . writeHead ( 400 , { "content-type" : "text/html" } ) ; return res . end ( ` usage: ${ MOUNT } /<name>.<tld>/ ` ) ; }
const name = m [ 1 ] . toLowerCase ( ) ;
const rest = m [ 2 ] || "/" ;
return serve ( res , name , rest , ` ${ MOUNT } / ${ name } / ` ) ;
} catch ( e ) { res . writeHead ( 500 ) ; res . end ( "gateway error: " + e . message ) ; }
} ) ;
server . listen ( PORT , "127.0.0.1" , ( ) => console . log ( ` bns public gateway on 127.0.0.1: ${ PORT } (path ${ MOUNT } , subdomain *. ${ SUB _BASE } ) ` ) ) ;
function home ( ) {
return ` <!doctype html><meta charset=utf-8><title>BNS Gateway</title>
< body style = "font:16px system-ui;max-width:640px;margin:3rem auto;padding:0 1rem;color:#e7eaf1;background:#0f1420" >
< h1 style = "color:#d6ff3d" > ⛓ BNS Gateway < / h 1 >
< p > View Bitcoin Cash < b > . bch < / b > n a m e s i n a n y b r o w s e r — n o i n s t a l l . < / p >
< ul >
< li > < a href = "/bns/hello.bch" > / b n s / h e l l o . b c h < / a > · o r < a h r e f = " h t t p s : / / h e l l o . $ { S U B _ B A S E } / " > h e l l o . $ { S U B _ B A S E } < / a > < / l i >
< li > < a href = "/bns/coinspectrum.deviant.bch" > / b n s / c o i n s p e c t r u m . d e v i a n t . b c h < / a > < / l i >
< / u l >
< p style = "color:#8b98a9;font-size:13px" > This gateway resolves names for you , so it can see which . bch
sites you request ( it keeps no logs — you ' re trusting that ) . For trustless resolution install the local
resolver / extension ; for IP privacy use Tor . By Silent Mode ( a Deviant project ) . < / p > < / b o d y > ` ;
}