feat(sirius-x): dashboard with sidebar, persistent sign-in, full DNS panel
The portal was a flat page with tabs that asked for the password or PIN on
every visit and offered little beyond a records form. Owners need a
control panel they can live in.
Sign in once: the recovery phrase is kept encrypted under a
non-extractable browser key (IndexedDB) so the next visit opens the
dashboard silently; sign-out or the Settings toggle destroys it. Payments
approve with one click unless "Ask for PIN before payments" is on.
Dashboard: left menu (Overview, Domain names, My TLD list, Wallet,
Register name/TLD, Settings). Per-name detail with a summary, a DNS
record table (A, AAAA, CNAME, MX, TXT, NS, SRV, CAA) that signs the
manifest, content & hosting (h, s3, p, ip, tls), a redirect tab (u),
ownership transfer (UPD that re-issues the certificate to the recipient)
and zone-file/JSON export. Per-TLD detail with policy, on/off, owner
registration and the public list of names under it. Overview flags names
that point nowhere or lack DNS.
2026-09-17 01:09:20 +02:00
// Sirius.X dashboard — the signed-in control panel for names and TLDs.
//
// One page, a left menu, several panes: Overview, Domain names (with a
// per-name detail: DNS records, content & hosting, redirect, transfer,
// export), My TLD list (with a per-TLD detail), Wallet, Settings.
//
// Trust model is unchanged from the old portal: the wallet lives in this
// browser; every on-chain change is a transaction signed here; DNS records
// are a manifest signed here and stored on Sia via the gateway. What is new
// is the persistent device session (js/session.js): sign in once, come back
// without a prompt until you sign out.
2026-09-17 01:40:10 +02:00
import * as BNS from "https://silentmode.st/js/bns-register.js?v=20260917sig" ;
feat(sirius-x): dashboard with sidebar, persistent sign-in, full DNS panel
The portal was a flat page with tabs that asked for the password or PIN on
every visit and offered little beyond a records form. Owners need a
control panel they can live in.
Sign in once: the recovery phrase is kept encrypted under a
non-extractable browser key (IndexedDB) so the next visit opens the
dashboard silently; sign-out or the Settings toggle destroys it. Payments
approve with one click unless "Ask for PIN before payments" is on.
Dashboard: left menu (Overview, Domain names, My TLD list, Wallet,
Register name/TLD, Settings). Per-name detail with a summary, a DNS
record table (A, AAAA, CNAME, MX, TXT, NS, SRV, CAA) that signs the
manifest, content & hosting (h, s3, p, ip, tls), a redirect tab (u),
ownership transfer (UPD that re-issues the certificate to the recipient)
and zone-file/JSON export. Per-TLD detail with policy, on/off, owner
registration and the public list of names under it. Overview flags names
that point nowhere or lack DNS.
2026-09-17 01:09:20 +02:00
const API = "https://silentmode.st" ;
const TLD _API = "https://navigate.st/api/tlds?include_hidden=1" ;
2026-09-17 03:28:50 +02:00
// Operator wallet (chipnet): shows the Admin panel link in the sidebar.
const OPERATOR _ADDRESS = "bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu" ;
feat(sirius-x): dashboard with sidebar, persistent sign-in, full DNS panel
The portal was a flat page with tabs that asked for the password or PIN on
every visit and offered little beyond a records form. Owners need a
control panel they can live in.
Sign in once: the recovery phrase is kept encrypted under a
non-extractable browser key (IndexedDB) so the next visit opens the
dashboard silently; sign-out or the Settings toggle destroys it. Payments
approve with one click unless "Ask for PIN before payments" is on.
Dashboard: left menu (Overview, Domain names, My TLD list, Wallet,
Register name/TLD, Settings). Per-name detail with a summary, a DNS
record table (A, AAAA, CNAME, MX, TXT, NS, SRV, CAA) that signs the
manifest, content & hosting (h, s3, p, ip, tls), a redirect tab (u),
ownership transfer (UPD that re-issues the certificate to the recipient)
and zone-file/JSON export. Per-TLD detail with policy, on/off, owner
registration and the public list of names under it. Overview flags names
that point nowhere or lack DNS.
2026-09-17 01:09:20 +02:00
const $ = ( id ) => document . getElementById ( id ) ;
const esc = ( s ) => String ( s ? ? "" ) . replace ( /[&<>"']/g , ( c ) => ( { "&" : "&" , "<" : "<" , ">" : ">" , '"' : """ , "'" : "'" } [ c ] ) ) ;
const fmtInt = ( n ) => String ( n ) . replace ( /\B(?=(\d{3})+(?!\d))/g , "," ) ;
const shortAddr = ( a ) => ( a ? a . replace ( /^[^:]+:/ , "" ) . slice ( 0 , 8 ) + "…" + a . slice ( - 6 ) : "—" ) ;
const sleep = ( ms ) => new Promise ( ( r ) => setTimeout ( r , ms ) ) ;
// ---------- state ----------
let wallet = null ;
const H = { names : [ ] , tlds : [ ] , wallet : null , chain : null , loadedAt : 0 } ;
const dnsCache = new Map ( ) ; // name -> { status: "none"|"ok"|"nopointer"|"error", seq, dns, raw }
let registryCache = { at : 0 , names : [ ] } ;
let current = { name : null , entry : null , manifestSeq : 0 , rows : [ ] , dirty : false } ;
let currentTld = null ;
// ---------- profile breadcrumb (shared with profile-menu.js) ----------
function writeProfile ( w ) {
try {
localStorage . setItem ( "siriusProfile" , JSON . stringify ( {
address : w . address , tokenAddress : w . tokenAddress , source : w . source ? ? "seed" , signedInAt : Date . now ( ) ,
} ) ) ;
} catch { }
window . dispatchEvent ( new Event ( "siriusProfileChanged" ) ) ;
}
function readProfile ( ) {
try { return JSON . parse ( localStorage . getItem ( "siriusProfile" ) || "null" ) ; } catch { return null ; }
}
function clearProfile ( ) {
try { localStorage . removeItem ( "siriusProfile" ) ; } catch { }
window . dispatchEvent ( new Event ( "siriusProfileChanged" ) ) ;
}
// ---------- session ----------
async function restoreSession ( ) {
if ( window . siriusWallet ) return window . siriusWallet ;
const S = window . siriusSession ;
if ( ! S ? . restore ) return null ;
try {
const s = await S . restore ( ) ;
if ( ! s ? . mnemonic ) return null ;
const w = await BNS . BuiltInWallet . fromMnemonic ( s . mnemonic , BNS . CHIPNET _PREFIX , s . accountPath || undefined ) ;
window . siriusWallet = w ;
return w ;
} catch { return null ; }
}
async function ensureSignInFlow ( ) {
for ( let i = 0 ; i < 80 && typeof window . siriusRenderSignInInline !== "function" ; i ++ ) await sleep ( 50 ) ;
return typeof window . siriusRenderSignInInline === "function" ;
}
async function paintInlineSignIn ( mode ) {
if ( ! ( await ensureSignInFlow ( ) ) ) return ;
const hasSaved = ( ( ) => { try { return ! ! localStorage . getItem ( "bns.wallet.v1" ) ; } catch { return false ; } } ) ( ) ;
window . siriusRenderSignInInline ( $ ( "signin-inline" ) , mode || ( hasSaved ? "unlock" : "import" ) ) ;
}
function showSignedOut ( ) {
wallet = null ;
window . siriusWallet = null ;
$ ( "me" ) . classList . add ( "hidden" ) ;
$ ( "signed-out" ) . classList . remove ( "hidden" ) ;
}
async function enter ( w ) {
wallet = w ;
window . siriusWallet = w ;
$ ( "signed-out" ) . classList . add ( "hidden" ) ;
$ ( "me" ) . classList . remove ( "hidden" ) ;
$ ( "side-addr" ) . textContent = w . address ;
$ ( "me-addr" ) . textContent = w . address ;
$ ( "me-taddr" ) . textContent = w . tokenAddress ;
$ ( "me-info" ) . textContent = ` watched addresses: ${ w . watchedAddresses . length } ` ;
$ ( "w-watched" ) . textContent = String ( w . watchedAddresses . length ) ;
2026-09-17 03:28:50 +02:00
// Operator tools are only useful to the operator wallet.
$ ( "side-admin" ) . classList . toggle ( "hidden" , w . address !== OPERATOR _ADDRESS ) ;
feat(sirius-x): dashboard with sidebar, persistent sign-in, full DNS panel
The portal was a flat page with tabs that asked for the password or PIN on
every visit and offered little beyond a records form. Owners need a
control panel they can live in.
Sign in once: the recovery phrase is kept encrypted under a
non-extractable browser key (IndexedDB) so the next visit opens the
dashboard silently; sign-out or the Settings toggle destroys it. Payments
approve with one click unless "Ask for PIN before payments" is on.
Dashboard: left menu (Overview, Domain names, My TLD list, Wallet,
Register name/TLD, Settings). Per-name detail with a summary, a DNS
record table (A, AAAA, CNAME, MX, TXT, NS, SRV, CAA) that signs the
manifest, content & hosting (h, s3, p, ip, tls), a redirect tab (u),
ownership transfer (UPD that re-issues the certificate to the recipient)
and zone-file/JSON export. Per-TLD detail with policy, on/off, owner
registration and the public list of names under it. Overview flags names
that point nowhere or lack DNS.
2026-09-17 01:09:20 +02:00
paintSettings ( ) ;
updateTldPrice ( ) ; updateTldButtons ( ) ;
routeFromHash ( ) ;
await loadHoldings ( ) ;
}
async function signOut ( { forget = false } = { } ) {
if ( wallet ? . source === "wc" && wallet . session ) { try { await wallet . session . disconnect ( ) ; } catch { } }
try { await window . siriusSession ? . forget ? . ( ) ; } catch { }
if ( forget ) {
try { BNS . BuiltInWallet . forget ( ) ; } catch { }
try { window . siriusPin ? . clear ? . ( ) ; } catch { }
}
clearProfile ( ) ;
showSignedOut ( ) ;
history . replaceState ( null , "" , location . pathname ) ;
await paintInlineSignIn ( forget ? "import" : "unlock" ) ;
}
window . addEventListener ( "siriusProfileChanged" , async ( ) => {
if ( readProfile ( ) ) { if ( window . siriusWallet && ! wallet ) await enter ( window . siriusWallet ) ; }
else if ( wallet ) { try { await window . siriusSession ? . forget ? . ( ) ; } catch { } showSignedOut ( ) ; await paintInlineSignIn ( ) ; }
} ) ;
window . addEventListener ( "storage" , async ( e ) => {
if ( e . key !== "siriusProfile" ) return ;
if ( ! readProfile ( ) && wallet ) { showSignedOut ( ) ; await paintInlineSignIn ( ) ; }
} ) ;
( async function bootstrap ( ) {
const mode = new URLSearchParams ( location . search ) . get ( "mode" ) ;
if ( window . siriusWallet ) return enter ( window . siriusWallet ) ;
const w = await restoreSession ( ) ;
if ( w ) { writeProfile ( w ) ; return enter ( w ) ; }
await paintInlineSignIn ( mode || undefined ) ;
} ) ( ) ;
// ---------- navigation ----------
feat(sirius-x): Sirius Studio web-builder + signed site uploads; shorter dashboard menu
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.
2026-09-17 01:35:21 +02:00
const PANES = [ "studio" , "domains" , "domain" , "tlds" , "tld" , "newtld" , "settings" ] ;
feat(sirius-x): dashboard with sidebar, persistent sign-in, full DNS panel
The portal was a flat page with tabs that asked for the password or PIN on
every visit and offered little beyond a records form. Owners need a
control panel they can live in.
Sign in once: the recovery phrase is kept encrypted under a
non-extractable browser key (IndexedDB) so the next visit opens the
dashboard silently; sign-out or the Settings toggle destroys it. Payments
approve with one click unless "Ask for PIN before payments" is on.
Dashboard: left menu (Overview, Domain names, My TLD list, Wallet,
Register name/TLD, Settings). Per-name detail with a summary, a DNS
record table (A, AAAA, CNAME, MX, TXT, NS, SRV, CAA) that signs the
manifest, content & hosting (h, s3, p, ip, tls), a redirect tab (u),
ownership transfer (UPD that re-issues the certificate to the recipient)
and zone-file/JSON export. Per-TLD detail with policy, on/off, owner
registration and the public list of names under it. Overview flags names
that point nowhere or lack DNS.
2026-09-17 01:09:20 +02:00
function go ( pane , arg ) {
feat(sirius-x): Sirius Studio web-builder + signed site uploads; shorter dashboard menu
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.
2026-09-17 01:35:21 +02:00
if ( ! PANES . includes ( pane ) ) pane = "domains" ;
feat(sirius-x): dashboard with sidebar, persistent sign-in, full DNS panel
The portal was a flat page with tabs that asked for the password or PIN on
every visit and offered little beyond a records form. Owners need a
control panel they can live in.
Sign in once: the recovery phrase is kept encrypted under a
non-extractable browser key (IndexedDB) so the next visit opens the
dashboard silently; sign-out or the Settings toggle destroys it. Payments
approve with one click unless "Ask for PIN before payments" is on.
Dashboard: left menu (Overview, Domain names, My TLD list, Wallet,
Register name/TLD, Settings). Per-name detail with a summary, a DNS
record table (A, AAAA, CNAME, MX, TXT, NS, SRV, CAA) that signs the
manifest, content & hosting (h, s3, p, ip, tls), a redirect tab (u),
ownership transfer (UPD that re-issues the certificate to the recipient)
and zone-file/JSON export. Per-TLD detail with policy, on/off, owner
registration and the public list of names under it. Overview flags names
that point nowhere or lack DNS.
2026-09-17 01:09:20 +02:00
document . querySelectorAll ( ".pane[data-pane]" ) . forEach ( ( p ) => { p . hidden = p . dataset . pane !== pane ; } ) ;
const activeItem = pane === "domain" ? "domains" : pane === "tld" ? "tlds" : pane ;
document . querySelectorAll ( ".side .item[data-go]" ) . forEach ( ( b ) => b . classList . toggle ( "active" , b . dataset . go === activeItem ) ) ;
const hash = arg ? ` # ${ pane } / ${ encodeURIComponent ( arg ) } ` : ` # ${ pane } ` ;
if ( location . hash !== hash ) history . replaceState ( null , "" , hash ) ;
if ( pane === "domain" && arg ) openDomain ( arg ) ;
if ( pane === "tld" && arg ) openTld ( arg ) ;
if ( pane === "tlds" ) renderTlds ( ) ;
if ( pane === "domains" ) renderDomains ( ) ;
feat(sirius-x): Sirius Studio web-builder + signed site uploads; shorter dashboard menu
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.
2026-09-17 01:35:21 +02:00
if ( pane === "studio" ) renderStudio ( ) ;
feat(sirius-x): dashboard with sidebar, persistent sign-in, full DNS panel
The portal was a flat page with tabs that asked for the password or PIN on
every visit and offered little beyond a records form. Owners need a
control panel they can live in.
Sign in once: the recovery phrase is kept encrypted under a
non-extractable browser key (IndexedDB) so the next visit opens the
dashboard silently; sign-out or the Settings toggle destroys it. Payments
approve with one click unless "Ask for PIN before payments" is on.
Dashboard: left menu (Overview, Domain names, My TLD list, Wallet,
Register name/TLD, Settings). Per-name detail with a summary, a DNS
record table (A, AAAA, CNAME, MX, TXT, NS, SRV, CAA) that signs the
manifest, content & hosting (h, s3, p, ip, tls), a redirect tab (u),
ownership transfer (UPD that re-issues the certificate to the recipient)
and zone-file/JSON export. Per-TLD detail with policy, on/off, owner
registration and the public list of names under it. Overview flags names
that point nowhere or lack DNS.
2026-09-17 01:09:20 +02:00
window . scrollTo ( { top : 0 , behavior : "smooth" } ) ;
}
function routeFromHash ( ) {
const m = /^#([a-z]+)(?:\/(.+))?$/ . exec ( location . hash || "" ) ;
feat(sirius-x): Sirius Studio web-builder + signed site uploads; shorter dashboard menu
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.
2026-09-17 01:35:21 +02:00
if ( ! m ) return go ( "domains" ) ;
feat(sirius-x): dashboard with sidebar, persistent sign-in, full DNS panel
The portal was a flat page with tabs that asked for the password or PIN on
every visit and offered little beyond a records form. Owners need a
control panel they can live in.
Sign in once: the recovery phrase is kept encrypted under a
non-extractable browser key (IndexedDB) so the next visit opens the
dashboard silently; sign-out or the Settings toggle destroys it. Payments
approve with one click unless "Ask for PIN before payments" is on.
Dashboard: left menu (Overview, Domain names, My TLD list, Wallet,
Register name/TLD, Settings). Per-name detail with a summary, a DNS
record table (A, AAAA, CNAME, MX, TXT, NS, SRV, CAA) that signs the
manifest, content & hosting (h, s3, p, ip, tls), a redirect tab (u),
ownership transfer (UPD that re-issues the certificate to the recipient)
and zone-file/JSON export. Per-TLD detail with policy, on/off, owner
registration and the public list of names under it. Overview flags names
that point nowhere or lack DNS.
2026-09-17 01:09:20 +02:00
go ( m [ 1 ] , m [ 2 ] ? decodeURIComponent ( m [ 2 ] ) : undefined ) ;
}
window . addEventListener ( "hashchange" , ( ) => { if ( wallet ) routeFromHash ( ) ; } ) ;
document . addEventListener ( "click" , ( e ) => {
const el = e . target . closest ( "[data-go]" ) ;
if ( ! el ) return ;
e . preventDefault ( ) ;
go ( el . dataset . go , el . dataset . arg ) ;
} ) ;
$ ( "side-signout" ) . addEventListener ( "click" , ( ) => signOut ( ) ) ;
$ ( "set-signout" ) . addEventListener ( "click" , ( ) => signOut ( ) ) ;
$ ( "set-forget" ) . addEventListener ( "click" , async ( ) => {
if ( ! confirm ( "Forget the wallet on this device? You will need your recovery phrase to sign in again." ) ) return ;
await signOut ( { forget : true } ) ;
} ) ;
$ ( "refresh-btn" ) . addEventListener ( "click" , ( ) => loadHoldings ( ) ) ;
document . querySelectorAll ( "[data-copy]" ) . forEach ( ( b ) => b . addEventListener ( "click" , ( ) => {
const t = $ ( b . dataset . copy ) ? . textContent || "" ;
navigator . clipboard ? . writeText ( t ) ;
const o = b . textContent ; b . textContent = "Copied" ; setTimeout ( ( ) => { b . textContent = o ; } , 1200 ) ;
} ) ) ;
// ---------- holdings ----------
async function loadHoldings ( ) {
const status = $ ( "names-status" ) ;
status . className = "status" ; status . textContent = "Loading names from the chain…" ;
try {
const scripthashes = wallet . watchedAddresses . map ( ( a ) => BNS . addressToScripthash ( a ) ) ;
const r = await fetch ( ` ${ API } /api/holdings ` , { method : "POST" , headers : { "content-type" : "application/json" } , body : JSON . stringify ( { scripthashes } ) } ) ;
if ( ! r . ok ) throw new Error ( ` API ${ r . status } : ${ ( await r . text ( ) . catch ( ( ) => "" ) ) . slice ( 0 , 200 ) } ` ) ;
const p = await r . json ( ) ;
H . names = ( p . names || [ ] ) . map ( ( n ) => ( { ... n , label : n . name . split ( "." ) [ 0 ] } ) ) ;
H . tlds = p . tlds || [ ] ;
H . wallet = p . wallet || null ; H . chain = p . chain || null ; H . loadedAt = Date . now ( ) ;
// header numbers
feat(sirius-x): Sirius Studio web-builder + signed site uploads; shorter dashboard menu
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.
2026-09-17 01:35:21 +02:00
const setText = ( id , v ) => { const el = $ ( id ) ; if ( el ) el . textContent = v ; } ;
setText ( "cnt-names" , H . names . length ) ; setText ( "cnt-tlds" , H . tlds . length ) ;
feat(sirius-x): dashboard with sidebar, persistent sign-in, full DNS panel
The portal was a flat page with tabs that asked for the password or PIN on
every visit and offered little beyond a records form. Owners need a
control panel they can live in.
Sign in once: the recovery phrase is kept encrypted under a
non-extractable browser key (IndexedDB) so the next visit opens the
dashboard silently; sign-out or the Settings toggle destroys it. Payments
approve with one click unless "Ask for PIN before payments" is on.
Dashboard: left menu (Overview, Domain names, My TLD list, Wallet,
Register name/TLD, Settings). Per-name detail with a summary, a DNS
record table (A, AAAA, CNAME, MX, TXT, NS, SRV, CAA) that signs the
manifest, content & hosting (h, s3, p, ip, tls), a redirect tab (u),
ownership transfer (UPD that re-issues the certificate to the recipient)
and zone-file/JSON export. Per-TLD detail with policy, on/off, owner
registration and the public list of names under it. Overview flags names
that point nowhere or lack DNS.
2026-09-17 01:09:20 +02:00
if ( H . wallet ? . sats != null ) {
const s = fmtInt ( H . wallet . sats ) ;
feat(sirius-x): Sirius Studio web-builder + signed site uploads; shorter dashboard menu
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.
2026-09-17 01:35:21 +02:00
setText ( "side-balance" , s ) ; setText ( "w-sats" , ` ${ s } sat ` ) ; setText ( "w-utxos" , String ( H . wallet . utxo _count ) ) ;
2026-09-17 03:28:50 +02:00
// The header wallet menu (profile-menu.js) shows this on every page.
try { localStorage . setItem ( "siriusWalletInfo" , JSON . stringify ( { sats : String ( H . wallet . sats ) , utxos : H . wallet . utxo _count , names : H . names . length , tlds : H . tlds . length , at : Date . now ( ) } ) ) ; } catch { }
feat(sirius-x): dashboard with sidebar, persistent sign-in, full DNS panel
The portal was a flat page with tabs that asked for the password or PIN on
every visit and offered little beyond a records form. Owners need a
control panel they can live in.
Sign in once: the recovery phrase is kept encrypted under a
non-extractable browser key (IndexedDB) so the next visit opens the
dashboard silently; sign-out or the Settings toggle destroys it. Payments
approve with one click unless "Ask for PIN before payments" is on.
Dashboard: left menu (Overview, Domain names, My TLD list, Wallet,
Register name/TLD, Settings). Per-name detail with a summary, a DNS
record table (A, AAAA, CNAME, MX, TXT, NS, SRV, CAA) that signs the
manifest, content & hosting (h, s3, p, ip, tls), a redirect tab (u),
ownership transfer (UPD that re-issues the certificate to the recipient)
and zone-file/JSON export. Per-TLD detail with policy, on/off, owner
registration and the public list of names under it. Overview flags names
that point nowhere or lack DNS.
2026-09-17 01:09:20 +02:00
}
feat(sirius-x): Sirius Studio web-builder + signed site uploads; shorter dashboard menu
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.
2026-09-17 01:35:21 +02:00
if ( H . chain ? . height != null ) setText ( "stat-height" , fmtInt ( H . chain . height ) ) ;
renderAttention ( ) ; renderDomains ( ) ; renderTlds ( ) ; renderStudio ( ) ;
feat(sirius-x): dashboard with sidebar, persistent sign-in, full DNS panel
The portal was a flat page with tabs that asked for the password or PIN on
every visit and offered little beyond a records form. Owners need a
control panel they can live in.
Sign in once: the recovery phrase is kept encrypted under a
non-extractable browser key (IndexedDB) so the next visit opens the
dashboard silently; sign-out or the Settings toggle destroys it. Payments
approve with one click unless "Ask for PIN before payments" is on.
Dashboard: left menu (Overview, Domain names, My TLD list, Wallet,
Register name/TLD, Settings). Per-name detail with a summary, a DNS
record table (A, AAAA, CNAME, MX, TXT, NS, SRV, CAA) that signs the
manifest, content & hosting (h, s3, p, ip, tls), a redirect tab (u),
ownership transfer (UPD that re-issues the certificate to the recipient)
and zone-file/JSON export. Per-TLD detail with policy, on/off, owner
registration and the public list of names under it. Overview flags names
that point nowhere or lack DNS.
2026-09-17 01:09:20 +02:00
if ( current . name ) { const e = H . names . find ( ( n ) => n . name === current . name ) ; if ( e ) { current . entry = e ; fillSummary ( e ) ; } }
warmDnsCache ( ) ;
} catch ( e ) {
status . className = "status err" ;
status . textContent = "Could not load names: " + ( e . message || e ) ;
}
}
// DNS presence per name, fetched in the background (4 at a time) so the
// list can show a badge and Overview can flag names without records.
async function fetchDns ( name , force = false ) {
if ( ! force && dnsCache . has ( name ) ) return dnsCache . get ( name ) ;
let out ;
try {
const r = await fetch ( ` ${ API } /api/records/ ${ encodeURIComponent ( name ) } ` , { cache : "no-store" } ) ;
if ( r . status === 404 ) out = { status : "none" , seq : 0 , dns : { } } ;
else if ( r . status === 409 ) out = { status : "nopointer" , seq : 0 , dns : { } } ;
else if ( ! r . ok ) out = { status : "error" , seq : 0 , dns : { } } ;
else { const j = await r . json ( ) ; out = { status : "ok" , seq : typeof j . seq === "number" ? j . seq : 0 , dns : j . dns || { } , raw : j } ; }
} catch { out = { status : "error" , seq : 0 , dns : { } } ; }
dnsCache . set ( name , out ) ;
return out ;
}
async function warmDnsCache ( ) {
const todo = H . names . filter ( ( n ) => ! dnsCache . has ( n . name ) ) . map ( ( n ) => n . name ) ;
let i = 0 ;
const worker = async ( ) => { while ( i < todo . length ) { const n = todo [ i ++ ] ; await fetchDns ( n ) ; updateDnsBadge ( n ) ; } } ;
await Promise . all ( [ worker ( ) , worker ( ) , worker ( ) , worker ( ) ] ) ;
renderAttention ( ) ;
}
function dnsCount ( d ) {
if ( ! d ) return 0 ;
return [ "A" , "AAAA" , "MX" , "TXT" , "NS" , "SRV" , "CAA" ] . reduce ( ( n , k ) => n + ( Array . isArray ( d [ k ] ) ? d [ k ] . length : 0 ) , 0 ) + ( d . CNAME ? 1 : 0 ) ;
}
function updateDnsBadge ( name ) {
const c = dnsCache . get ( name ) ;
document . querySelectorAll ( ` [data-dnsbadge=" ${ CSS . escape ( name ) } "] ` ) . forEach ( ( el ) => {
const n = c ? . status === "ok" ? dnsCount ( c . dns ) : 0 ;
el . className = "badge " + ( n ? "b-ok" : "b-dim" ) ;
el . textContent = n ? ` DNS · ${ n } ` : ( c ? . status === "nopointer" ? "DNS needs s3" : "no DNS" ) ;
} ) ;
if ( current . name === name ) fillDnsBadge ( ) ;
}
// ---------- classification helpers ----------
function pointsTo ( rec ) {
if ( ! rec ) return { kind : "none" , label : "not pointed" , cls : "b-warn" } ;
if ( rec . h ) return { kind : "h" , label : "site on chain" , cls : "b-x" } ;
if ( rec . s3 ) return { kind : "s3" , label : "site on Sia" , cls : "b-x" } ;
if ( rec . p ) return { kind : "p" , label : "reverse proxy" , cls : "b-x" } ;
if ( rec . ip ) return { kind : "ip" , label : "own server" , cls : "b-x" } ;
if ( rec . u ) return { kind : "u" , label : "redirect" , cls : "b-x" } ;
return { kind : "none" , label : "not pointed" , cls : "b-warn" } ;
}
const siteUrl = ( name ) => ` ${ API } /bns/ ${ encodeURIComponent ( name ) } / ` ;
const txUrl = ( txid ) => ` https://chipnet.imaginary.cash/tx/ ${ txid } ` ;
feat(sirius-x): Sirius Studio web-builder + signed site uploads; shorter dashboard menu
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.
2026-09-17 01:35:21 +02:00
// ---------- attention strip (top of Domain names) ----------
feat(sirius-x): dashboard with sidebar, persistent sign-in, full DNS panel
The portal was a flat page with tabs that asked for the password or PIN on
every visit and offered little beyond a records form. Owners need a
control panel they can live in.
Sign in once: the recovery phrase is kept encrypted under a
non-extractable browser key (IndexedDB) so the next visit opens the
dashboard silently; sign-out or the Settings toggle destroys it. Payments
approve with one click unless "Ask for PIN before payments" is on.
Dashboard: left menu (Overview, Domain names, My TLD list, Wallet,
Register name/TLD, Settings). Per-name detail with a summary, a DNS
record table (A, AAAA, CNAME, MX, TXT, NS, SRV, CAA) that signs the
manifest, content & hosting (h, s3, p, ip, tls), a redirect tab (u),
ownership transfer (UPD that re-issues the certificate to the recipient)
and zone-file/JSON export. Per-TLD detail with policy, on/off, owner
registration and the public list of names under it. Overview flags names
that point nowhere or lack DNS.
2026-09-17 01:09:20 +02:00
function renderAttention ( ) {
const items = [ ] ;
for ( const e of H . names ) {
const pt = pointsTo ( e . records ) ;
if ( pt . kind === "none" ) items . push ( { e , why : "points nowhere yet" , go : "content" } ) ;
}
feat(sirius-x): Sirius Studio web-builder + signed site uploads; shorter dashboard menu
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.
2026-09-17 01:35:21 +02:00
const box = $ ( "ov-attention" ) ;
if ( ! items . length ) { box . innerHTML = "" ; return ; }
box . innerHTML = ` <div class="dim" style="margin:6px 0 4px">Needs attention</div> ` +
items . slice ( 0 , 5 ) . map ( ( { e , why , go } ) => ` <div class="drow" data-open=" ${ esc ( e . name ) } " data-sub=" ${ go } "><div class="n"> ${ nameHtml ( e ) } </div><span class="badge b-warn"> ${ esc ( why ) } </span></div> ` ) . join ( "" ) ;
}
// ---------- web-builder (Sirius Studio) ----------
function renderStudio ( ) {
const status = $ ( "studio-status" ) , list = $ ( "studio-list" ) ;
if ( ! status || ! list ) return ;
if ( ! H . names . length ) {
status . className = "status" ;
status . innerHTML = ` You need a name first. <a href="./#search-input">Register one →</a> — then come back here to build its site. ` ;
list . innerHTML = "" ; return ;
}
status . className = "status ok" ;
status . textContent = ` Pick a name to build or edit its site. ` ;
list . innerHTML = [ ... H . names ] . sort ( ( a , b ) => a . name . localeCompare ( b . name ) ) . map ( ( e ) => {
const rec = e . records || { } ;
const studioFolder = ` bns/ ${ e . name } / ` ;
const st = rec . s3 === studioFolder ? { label : "built with Studio" , cls : "b-ok" }
: rec . s3 ? { label : "site on Sia (external)" , cls : "b-x" }
: rec . h ? { label : "inline HTML" , cls : "b-x" }
: rec . u ? { label : "redirect" , cls : "b-dim" }
: rec . p || rec . ip ? { label : "own server" , cls : "b-dim" }
: { label : "no site yet" , cls : "b-warn" } ;
return ` <div class="drow" style="cursor:default">
< div class = "n" > $ { nameHtml ( e ) } < span class = "badge ${st.cls}" > $ { esc ( st . label ) } < / s p a n > < / d i v >
< div class = "acts" >
$ { rec . s3 || rec . h ? ` <a class="btn small ghost" href=" ${ siteUrl ( e . name ) } " target="_blank" rel="noopener">View →</a> ` : "" }
< a class = "btn small acid" href = "./studio.html?name=${encodeURIComponent(e.name)}" > $ { rec . s3 === studioFolder ? "Edit in Studio" : "Open in Studio" } → < / a >
< / d i v >
< / d i v > ` ;
} ) . join ( "" ) ;
feat(sirius-x): dashboard with sidebar, persistent sign-in, full DNS panel
The portal was a flat page with tabs that asked for the password or PIN on
every visit and offered little beyond a records form. Owners need a
control panel they can live in.
Sign in once: the recovery phrase is kept encrypted under a
non-extractable browser key (IndexedDB) so the next visit opens the
dashboard silently; sign-out or the Settings toggle destroys it. Payments
approve with one click unless "Ask for PIN before payments" is on.
Dashboard: left menu (Overview, Domain names, My TLD list, Wallet,
Register name/TLD, Settings). Per-name detail with a summary, a DNS
record table (A, AAAA, CNAME, MX, TXT, NS, SRV, CAA) that signs the
manifest, content & hosting (h, s3, p, ip, tls), a redirect tab (u),
ownership transfer (UPD that re-issues the certificate to the recipient)
and zone-file/JSON export. Per-TLD detail with policy, on/off, owner
registration and the public list of names under it. Overview flags names
that point nowhere or lack DNS.
2026-09-17 01:09:20 +02:00
}
// ---------- domains list ----------
function nameHtml ( e ) { return ` <span> ${ esc ( e . label ) } .<span class="tld"> ${ esc ( e . tld ) } </span></span> ` ; }
function domainRow ( e , { compact = false } = { } ) {
const pt = pointsTo ( e . records ) ;
const d = dnsCache . get ( e . name ) ;
const dn = d ? . status === "ok" ? dnsCount ( d . dns ) : 0 ;
const dnsBadge = ` <span class="badge ${ dn ? "b-ok" : "b-dim" } " data-dnsbadge=" ${ esc ( e . name ) } "> ${ dn ? ` DNS · ${ dn } ` : ( d ? . status === "nopointer" ? "DNS needs s3" : ( d ? "no DNS" : "DNS …" ) ) } </span> ` ;
const acts = compact ? "" : ` <div class="acts">
< a class = "btn small ghost" href = "${siteUrl(e.name)}" target = "_blank" rel = "noopener" data - stop > Open → < / a >
< button class = "btn small ghost" data - open = "${esc(e.name)}" data - sub = "dns" > DNS < / b u t t o n >
< button class = "btn small acid" data - open = "${esc(e.name)}" data - sub = "summary" > Manage < / b u t t o n > < / d i v > ` ;
return ` <div class="drow" data-open=" ${ esc ( e . name ) } " data-sub="summary">
< div class = "n" > $ { nameHtml ( e ) } < span class = "badge ${pt.cls}" > $ { esc ( pt . label ) } < / s p a n > $ { d n s B a d g e } < / d i v >
$ { acts }
$ { compact ? "" : ` <div class="meta"><span>block ${ e . height ? fmtInt ( e . height ) : "mempool" } </span><span>·</span><span class="mono" style="background:none;border:0;padding:0"> ${ esc ( ( e . category || "" ) . slice ( 0 , 12 ) ) } …</span></div> ` }
< / d i v > ` ;
}
function renderDomains ( ) {
const list = $ ( "names-list" ) , status = $ ( "names-status" ) ;
const q = ( $ ( "dom-search" ) . value || "" ) . trim ( ) . toLowerCase ( ) ;
const sort = $ ( "dom-sort" ) . value ;
const tldSel = $ ( "dom-tld" ) ;
// TLD filter options (rebuild keeping selection)
const tlds = [ ... new Set ( H . names . map ( ( n ) => n . tld ) ) ] . sort ( ) ;
const keep = tldSel . value ;
tldSel . innerHTML = ` <option value="">All TLDs</option> ` + tlds . map ( ( t ) => ` <option value=" ${ esc ( t ) } " ${ t === keep ? " selected" : "" } >. ${ esc ( t ) } </option> ` ) . join ( "" ) ;
let rows = H . names . filter ( ( n ) => ( ! q || n . name . includes ( q ) ) && ( ! tldSel . value || n . tld === tldSel . value ) ) ;
rows . sort ( ( a , b ) => sort === "tld" ? ( a . tld . localeCompare ( b . tld ) || a . label . localeCompare ( b . label ) )
: sort === "height" ? ( ( b . height || 1e12 ) - ( a . height || 1e12 ) )
: a . name . localeCompare ( b . name ) ) ;
$ ( "dom-count" ) . textContent = ` ${ rows . length } of ${ H . names . length } ` ;
if ( ! H . names . length ) {
status . className = "status" ; status . textContent = "No BCNR names found for this wallet. If you just registered, wait a minute for confirmation and refresh." ;
list . innerHTML = "" ; return ;
}
status . className = "status ok" ; status . textContent = ` You own ${ H . names . length } name ${ H . names . length === 1 ? "" : "s" } . ` ;
list . innerHTML = rows . length ? rows . map ( ( e ) => domainRow ( e ) ) . join ( "" ) : ` <div class="empty">Nothing matches that filter.</div> ` ;
}
[ "dom-search" , "dom-sort" , "dom-tld" ] . forEach ( ( id ) => $ ( id ) . addEventListener ( "input" , renderDomains ) ) ;
document . addEventListener ( "click" , ( e ) => {
if ( e . target . closest ( "[data-stop]" ) ) return ;
const el = e . target . closest ( "[data-open]" ) ;
if ( ! el ) return ;
e . preventDefault ( ) ;
pendingSub = el . dataset . sub || "summary" ;
go ( "domain" , el . dataset . open ) ;
} ) ;
let pendingSub = "summary" ;
// ---------- domain detail ----------
function setSub ( name ) {
document . querySelectorAll ( ".subtabs [data-sub]" ) . forEach ( ( b ) => b . classList . toggle ( "active" , b . dataset . sub === name ) ) ;
document . querySelectorAll ( '.pane[data-pane="domain"] .sub[data-sub]' ) . forEach ( ( s ) => { s . hidden = s . dataset . sub !== name ; } ) ;
if ( name === "export" ) renderExportPreview ( ) ;
}
document . querySelectorAll ( ".subtabs [data-sub]" ) . forEach ( ( b ) => b . addEventListener ( "click" , ( ) => setSub ( b . dataset . sub ) ) ) ;
async function openDomain ( name ) {
const entry = H . names . find ( ( n ) => n . name === name ) ;
if ( ! entry ) {
if ( ! H . names . length && Date . now ( ) - H . loadedAt > 1000 ) { $ ( "dd-name" ) . textContent = name ; $ ( "dd-crumb" ) . textContent = name ; return ; }
$ ( "dd-name" ) . textContent = name ; $ ( "dd-crumb" ) . textContent = name ;
$ ( "dd-kv" ) . innerHTML = ` <div class="k">Status</div><div class="v">This wallet does not hold ${ esc ( name ) } .</div> ` ;
return ;
}
current = { name , entry , manifestSeq : 0 , rows : [ ] , dirty : false } ;
$ ( "dd-name" ) . textContent = name ; $ ( "dd-crumb" ) . textContent = name ;
$ ( "dd-view" ) . href = siteUrl ( name ) ;
fillSummary ( entry ) ;
fillContentForm ( entry . records || { } ) ;
$ ( "rd-url" ) . value = entry . records ? . u || "" ;
$ ( "tr-addr" ) . value = "" ; $ ( "tr-ack" ) . checked = false ; $ ( "tr-send" ) . disabled = true ; setMsg ( "tr-msg" , "" ) ;
setMsg ( "dns-msg" , "" ) ; setMsg ( "ct-msg" , "" ) ; setMsg ( "rd-msg" , "" ) ;
setSub ( pendingSub || "summary" ) ; pendingSub = "summary" ;
await loadDns ( name , true ) ;
}
function setMsg ( id , text , cls = "" ) { const m = $ ( id ) ; if ( ! m ) return ; m . textContent = text ; m . className = "msg " + cls ; }
function logger ( id ) {
const log = $ ( id ) ; log . className = "steps-log on" ; log . innerHTML = "" ;
return ( t ) => { const d = document . createElement ( "div" ) ; d . textContent = String ( t ) ; log . appendChild ( d ) ; log . scrollTop = log . scrollHeight ; } ;
}
function fillSummary ( e ) {
const kv = [
[ "Name" , ` <code> ${ esc ( e . name ) } </code> ` ] ,
[ "TLD" , ` . ${ esc ( e . tld ) } ` ] ,
[ "Owner" , ` <code> ${ esc ( wallet . address ) } </code> ` ] ,
[ "Certificate" , ` <code> ${ esc ( e . category || "" ) } </code> ` ] ,
[ "Registered" , e . height ? ` block ${ fmtInt ( e . height ) } · <a href=" ${ txUrl ( e . txid ) } " target="_blank" rel="noopener">tx →</a> ` : ` mempool · <a href=" ${ txUrl ( e . txid ) } " target="_blank" rel="noopener">tx →</a> ` ] ,
[ "Last update" , e . updated _txid ? ` <a href=" ${ txUrl ( e . updated _txid ) } " target="_blank" rel="noopener"> ${ esc ( e . updated _txid . slice ( 0 , 16 ) ) } … →</a> ` : "none since registration" ] ,
[ "Renewal" , "never — names do not expire" ] ,
[ "Public URLs" , ` <a href=" ${ siteUrl ( e . name ) } " target="_blank" rel="noopener">silentmode.st gateway</a> · <a href="https://navigate.st/bns/ ${ encodeURIComponent ( e . name ) } /" target="_blank" rel="noopener">navigate.st</a> ` ] ,
] ;
$ ( "dd-kv" ) . innerHTML = kv . map ( ( [ k , v ] ) => ` <div class="k"> ${ k } </div><div class="v"> ${ v } </div> ` ) . join ( "" ) ;
const rec = e . records || { } ;
const pt = pointsTo ( rec ) ;
const rows = [ [ "Serves" , ` <span class="badge ${ pt . cls } "> ${ esc ( pt . label ) } </span> ` ] ] ;
const labels = { h : "Inline HTML" , s3 : "Sia bucket" , p : "Reverse proxy" , ip : "Server IPv4" , tls : "TLS pin" , u : "Redirect" } ;
for ( const k of [ "h" , "s3" , "p" , "ip" , "tls" , "u" ] ) if ( rec [ k ] ) rows . push ( [ labels [ k ] , ` <code> ${ esc ( String ( rec [ k ] ) . slice ( 0 , 120 ) ) } </code> ` ] ) ;
if ( rows . length === 1 ) rows . push ( [ "Records" , ` none — <a href="#" data-sub-go="content">point it somewhere →</a> ` ] ) ;
$ ( "dd-records" ) . innerHTML = rows . map ( ( [ k , v ] ) => ` <div class="k"> ${ k } </div><div class="v"> ${ v } </div> ` ) . join ( "" ) ;
$ ( "dd-records" ) . querySelectorAll ( "[data-sub-go]" ) . forEach ( ( a ) => a . onclick = ( ev ) => { ev . preventDefault ( ) ; setSub ( a . dataset . subGo ) ; } ) ;
fillDnsBadge ( ) ;
}
function fillDnsBadge ( ) {
const c = dnsCache . get ( current . name ) ;
const n = c ? . status === "ok" ? dnsCount ( c . dns ) : 0 ;
const b = $ ( "dd-dnsbadge" ) ;
b . className = "badge " + ( n ? "b-ok" : "b-dim" ) ;
b . textContent = n ? ` DNS · ${ n } record ${ n === 1 ? "" : "s" } ` : ( c ? . status === "nopointer" ? "DNS needs s3" : "no DNS" ) ;
const sum = $ ( "dd-dns-summary" ) ;
if ( ! c ) { sum . textContent = "Loading…" ; return ; }
if ( c . status === "nopointer" ) { sum . innerHTML = ` This name has no <b>s3</b> record, so there is nowhere to store a DNS manifest yet. Set one under Content & hosting first. ` ; return ; }
if ( ! n ) { sum . innerHTML = ` No DNS records. <a href="#" data-sub-go="dns">Add some →</a> ` ; sum . querySelector ( "[data-sub-go]" ) . onclick = ( ev ) => { ev . preventDefault ( ) ; setSub ( "dns" ) ; } ; return ; }
const d = c . dns ;
const parts = [ ] ;
for ( const k of [ "A" , "AAAA" , "NS" , "TXT" ] ) if ( Array . isArray ( d [ k ] ) && d [ k ] . length ) parts . push ( ` <b> ${ k } </b> ${ d [ k ] . map ( ( v ) => esc ( String ( v ) . slice ( 0 , 48 ) ) ) . join ( ", " ) } ` ) ;
if ( d . CNAME ) parts . push ( ` <b>CNAME</b> ${ esc ( d . CNAME ) } ` ) ;
if ( Array . isArray ( d . MX ) && d . MX . length ) parts . push ( ` <b>MX</b> ${ d . MX . map ( ( m ) => ` ${ m . pref } ${ esc ( m . host ) } ` ) . join ( ", " ) } ` ) ;
if ( Array . isArray ( d . SRV ) && d . SRV . length ) parts . push ( ` <b>SRV</b> ${ d . SRV . length } ` ) ;
if ( Array . isArray ( d . CAA ) && d . CAA . length ) parts . push ( ` <b>CAA</b> ${ d . CAA . map ( ( x ) => ` ${ x . tag } ${ esc ( x . value ) } ` ) . join ( ", " ) } ` ) ;
sum . innerHTML = parts . join ( "<br>" ) + ` <div class="dim" style="margin-top:6px">manifest seq ${ c . seq } </div> ` ;
}
// ---------- DNS editor ----------
const DNS _HELP = {
A : [ "IPv4 address" , "1.2.3.4" ] , AAAA : [ "IPv6 address" , "2001:db8::1" ] , CNAME : [ "Target host (one CNAME per name)" , "target.example.com" ] ,
MX : [ "Mail host" , "mail.example.com" ] , TXT : [ "Text (SPF, DKIM, verification…)" , "v=spf1 include:_spf.silentmode.st -all" ] ,
NS : [ "Nameserver host" , "ns1.example.com" ] , SRV : [ "Target host" , "sip.example.com" ] , CAA : [ "CA domain or report URL" , "letsencrypt.org" ] ,
} ;
function rowsFromManifest ( d ) {
const rows = [ ] ;
for ( const t of [ "A" , "AAAA" , "TXT" , "NS" ] ) for ( const v of ( Array . isArray ( d ? . [ t ] ) ? d [ t ] : [ ] ) ) rows . push ( { type : t , value : String ( v ) } ) ;
if ( d ? . CNAME ) rows . push ( { type : "CNAME" , value : String ( d . CNAME ) } ) ;
for ( const m of ( Array . isArray ( d ? . MX ) ? d . MX : [ ] ) ) rows . push ( { type : "MX" , value : String ( m . host ) , prio : Number ( m . pref ) || 0 } ) ;
for ( const s of ( Array . isArray ( d ? . SRV ) ? d . SRV : [ ] ) ) rows . push ( { type : "SRV" , value : String ( s . target ) , prio : Number ( s . priority ) || 0 , weight : Number ( s . weight ) || 0 , port : Number ( s . port ) || 0 } ) ;
for ( const c of ( Array . isArray ( d ? . CAA ) ? d . CAA : [ ] ) ) rows . push ( { type : "CAA" , value : String ( c . value ) , tag : c . tag || "issue" , flags : Number ( c . flags ) || 0 } ) ;
return rows ;
}
function manifestFromRows ( rows ) {
const d = { A : [ ] , AAAA : [ ] , MX : [ ] , TXT : [ ] , CNAME : null , NS : [ ] } ;
const srv = [ ] , caa = [ ] ;
for ( const r of rows ) {
if ( r . type === "MX" ) d . MX . push ( { pref : r . prio || 0 , host : r . value } ) ;
else if ( r . type === "CNAME" ) d . CNAME = r . value ;
else if ( r . type === "SRV" ) srv . push ( { priority : r . prio || 0 , weight : r . weight || 0 , port : r . port || 0 , target : r . value } ) ;
else if ( r . type === "CAA" ) caa . push ( { flags : r . flags || 0 , tag : r . tag || "issue" , value : r . value } ) ;
else if ( d [ r . type ] ) d [ r . type ] . push ( r . value ) ;
}
if ( srv . length ) d . SRV = srv ;
if ( caa . length ) d . CAA = caa ;
return d ;
}
function extraOf ( r ) {
if ( r . type === "MX" ) return ` priority ${ r . prio } ` ;
if ( r . type === "SRV" ) return ` prio ${ r . prio } · weight ${ r . weight } · port ${ r . port } ` ;
if ( r . type === "CAA" ) return ` ${ r . tag } ${ r . flags ? ` · flags ${ r . flags } ` : "" } ` ;
return "" ;
}
function renderDnsTable ( ) {
const tb = $ ( "dns-table" ) . querySelector ( "tbody" ) ;
tb . innerHTML = current . rows . map ( ( r , i ) => ` <tr>
< td class = "t" > $ { esc ( r . type ) } < / t d > < t d c l a s s = " v " > $ { e s c ( r . v a l u e ) } < / t d > < t d c l a s s = " p " > $ { e s c ( e x t r a O f ( r ) ) } < / t d >
< td class = "a" > < button class = "x" data - del = "${i}" title = "Remove" > × < / b u t t o n > < / t d > < / t r > ` ) . j o i n ( " " ) ;
$ ( "dns-empty" ) . classList . toggle ( "hidden" , current . rows . length > 0 ) ;
$ ( "dns-seq" ) . textContent = ` seq ${ current . manifestSeq } ${ current . dirty ? " · unsaved" : "" } ` ;
tb . querySelectorAll ( "[data-del]" ) . forEach ( ( b ) => b . onclick = ( ) => { current . rows . splice ( Number ( b . dataset . del ) , 1 ) ; current . dirty = true ; renderDnsTable ( ) ; } ) ;
}
async function loadDns ( name , force ) {
const c = await fetchDns ( name , force ) ;
if ( current . name !== name ) return ;
current . manifestSeq = c . seq || 0 ;
current . rows = c . status === "ok" ? rowsFromManifest ( c . dns ) : [ ] ;
current . dirty = false ;
renderDnsTable ( ) ;
updateDnsBadge ( name ) ;
const pub = $ ( "dns-publish" ) ;
if ( c . status === "nopointer" ) {
setMsg ( "dns-msg" , "This name has no s3 record on chain, so the manifest has nowhere to live. Set one under Content & hosting first (any Sia bucket key works)." , "err" ) ;
pub . disabled = true ;
} else { pub . disabled = false ; }
}
function updateDnsForm ( ) {
const t = $ ( "dns-type" ) . value ;
document . querySelectorAll ( ".addrow .opt" ) . forEach ( ( o ) => { o . hidden = ! o . dataset . for . split ( " " ) . includes ( t ) ; } ) ;
const [ label , ph ] = DNS _HELP [ t ] || [ "Value" , "" ] ;
$ ( "dns-value-label" ) . textContent = label ; $ ( "dns-value" ) . placeholder = ph ;
$ ( "dns-help" ) . textContent = t === "CNAME" ? "A CNAME replaces any existing CNAME; classic DNS does not allow it next to other records on the same host." :
t === "TXT" ? "One string per record. Long DKIM keys are fine — there is no size cap in the manifest." :
t === "CAA" ? "Restricts which certificate authorities may issue for this name in classic DNS." : "" ;
}
$ ( "dns-type" ) . addEventListener ( "change" , updateDnsForm ) ; updateDnsForm ( ) ;
$ ( "dns-add" ) . addEventListener ( "click" , ( ) => {
const type = $ ( "dns-type" ) . value ;
const value = $ ( "dns-value" ) . value . trim ( ) ;
if ( ! value ) { $ ( "dns-value" ) . focus ( ) ; return ; }
if ( type === "A" && ! /^\d{1,3}(\.\d{1,3}){3}$/ . test ( value ) ) return setMsg ( "dns-msg" , "That is not an IPv4 address." , "err" ) ;
if ( type === "AAAA" && ! /^[0-9a-f:]+$/i . test ( value ) ) return setMsg ( "dns-msg" , "That is not an IPv6 address." , "err" ) ;
const row = { type , value } ;
if ( type === "MX" || type === "SRV" ) row . prio = Number ( $ ( "dns-prio" ) . value ) || 0 ;
if ( type === "SRV" ) { row . weight = Number ( $ ( "dns-weight" ) . value ) || 0 ; row . port = Number ( $ ( "dns-port" ) . value ) || 0 ; }
if ( type === "CAA" ) { row . tag = $ ( "dns-tag" ) . value ; row . flags = 0 ; }
if ( type === "CNAME" ) current . rows = current . rows . filter ( ( r ) => r . type !== "CNAME" ) ;
current . rows . push ( row ) ; current . dirty = true ;
$ ( "dns-value" ) . value = "" ; $ ( "dns-prio" ) . value = "" ; $ ( "dns-weight" ) . value = "" ; $ ( "dns-port" ) . value = "" ;
setMsg ( "dns-msg" , "" ) ; renderDnsTable ( ) ;
} ) ;
$ ( "dns-value" ) . addEventListener ( "keydown" , ( e ) => { if ( e . key === "Enter" ) { e . preventDefault ( ) ; $ ( "dns-add" ) . click ( ) ; } } ) ;
$ ( "dns-reload" ) . addEventListener ( "click" , ( ) => loadDns ( current . name , true ) ) ;
$ ( "dns-publish" ) . addEventListener ( "click" , async ( ) => {
if ( ! current . name || ! wallet ) return ;
if ( wallet . source === "wc" ) return setMsg ( "dns-msg" , "External-wallet signing (WizardConnect) for records manifests lands in the next drop — please sign in with the built-in wallet." , "err" ) ;
const push = logger ( "dns-log" ) ; const btn = $ ( "dns-publish" ) ;
btn . disabled = true ; btn . textContent = "Signing…" ; setMsg ( "dns-msg" , "" ) ;
try {
const manifest = { v : 1 , name : current . name , seq : ( current . manifestSeq || 0 ) + 1 , updated _at : new Date ( ) . toISOString ( ) , dns : manifestFromRows ( current . rows ) } ;
push ( ` composed manifest · seq ${ manifest . seq } · ${ current . rows . length } record ${ current . rows . length === 1 ? "" : "s" } ` ) ;
const signed = await BNS . signRecordsManifest ( wallet , manifest ) ;
push ( "signed with wallet key" ) ;
const r = await fetch ( ` ${ API } /api/records/ ${ encodeURIComponent ( current . name ) } ` , { method : "POST" , headers : { "content-type" : "application/json" } , body : JSON . stringify ( signed ) } ) ;
const j = await r . json ( ) . catch ( ( ) => ( { } ) ) ;
if ( ! r . ok ) throw new Error ( ` API ${ r . status } : ${ j . error || "unknown error" } ` ) ;
push ( ` published · ${ j . bytes || "?" } bytes at ${ j . sia _key } ` ) ;
current . manifestSeq = manifest . seq ; current . dirty = false ;
dnsCache . set ( current . name , { status : "ok" , seq : manifest . seq , dns : manifest . dns , raw : manifest } ) ;
renderDnsTable ( ) ; updateDnsBadge ( current . name ) ; renderAttention ( ) ;
setMsg ( "dns-msg" , ` Published manifest seq ${ manifest . seq } . Resolvers pick it up on their next fetch (about 30 s at the gateway). ` , "ok" ) ;
} catch ( e ) { setMsg ( "dns-msg" , "Failed: " + ( e . message || e ) , "err" ) ; push ( "error: " + ( e . message || e ) ) ; }
finally { btn . disabled = false ; btn . textContent = "Sign & publish →" ; }
} ) ;
// ---------- content & hosting (on-chain records) ----------
const CT _KEYS = [ "h" , "s3" , "p" , "ip" , "tls" ] ;
function fillContentForm ( rec ) { for ( const k of CT _KEYS ) $ ( "ct-" + k ) . value = rec [ k ] || "" ; updateBudget ( ) ; }
function collectContent ( ) {
const r = { } ;
for ( const k of CT _KEYS ) { const v = $ ( "ct-" + k ) . value . trim ( ) ; if ( v ) r [ k ] = v ; }
const u = ( $ ( "rd-url" ) . value || "" ) . trim ( ) ; if ( u ) r . u = u ;
return r ;
}
function updateBudget ( ) {
if ( ! current . name ) return ;
try {
const max = BNS . payloadBudget ( current . name , "UPD" ) ;
const used = new TextEncoder ( ) . encode ( JSON . stringify ( collectContent ( ) ) ) . length ;
$ ( "ct-budget-max" ) . textContent = max ; $ ( "ct-budget-used" ) . textContent = used ;
$ ( "ct-budget" ) . classList . toggle ( "over" , used > max ) ; $ ( "ct-save" ) . disabled = used > max ;
} catch { }
}
CT _KEYS . forEach ( ( k ) => $ ( "ct-" + k ) . addEventListener ( "input" , updateBudget ) ) ;
$ ( "rd-url" ) . addEventListener ( "input" , updateBudget ) ;
$ ( "ct-reset" ) . addEventListener ( "click" , ( ) => fillContentForm ( current . entry ? . records || { } ) ) ;
async function publishRecords ( records , logId , msgId , btnId , doneText ) {
if ( ! current . name || ! wallet ) return false ;
const push = logger ( logId ) ; const btn = $ ( btnId ) ; const orig = btn . textContent ;
btn . disabled = true ; btn . textContent = "Signing…" ; setMsg ( msgId , "" ) ;
let el = null ;
try {
el = await BNS . connect ( ) ; push ( "connected to chipnet" ) ;
const res = wallet . source === "wc"
? await BNS . setRecordsWithExternalWallet ( el , { session : wallet . session , name : current . name , records , onProgress : ( s ) => push ( s ) } )
: await BNS . setRecordsWithBuiltInWallet ( el , { wallet , name : current . name , records , onProgress : ( s ) => push ( s ) } ) ;
push ( "broadcast: " + ( res . txid || res . txId || "(no txid)" ) ) ;
if ( current . entry ) { current . entry . records = records ; current . entry . updated _txid = res . txid || res . txId || current . entry . updated _txid ; }
feat(sirius-x): Sirius Studio web-builder + signed site uploads; shorter dashboard menu
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.
2026-09-17 01:35:21 +02:00
fillSummary ( current . entry ) ; renderDomains ( ) ; renderAttention ( ) ; renderStudio ( ) ;
feat(sirius-x): dashboard with sidebar, persistent sign-in, full DNS panel
The portal was a flat page with tabs that asked for the password or PIN on
every visit and offered little beyond a records form. Owners need a
control panel they can live in.
Sign in once: the recovery phrase is kept encrypted under a
non-extractable browser key (IndexedDB) so the next visit opens the
dashboard silently; sign-out or the Settings toggle destroys it. Payments
approve with one click unless "Ask for PIN before payments" is on.
Dashboard: left menu (Overview, Domain names, My TLD list, Wallet,
Register name/TLD, Settings). Per-name detail with a summary, a DNS
record table (A, AAAA, CNAME, MX, TXT, NS, SRV, CAA) that signs the
manifest, content & hosting (h, s3, p, ip, tls), a redirect tab (u),
ownership transfer (UPD that re-issues the certificate to the recipient)
and zone-file/JSON export. Per-TLD detail with policy, on/off, owner
registration and the public list of names under it. Overview flags names
that point nowhere or lack DNS.
2026-09-17 01:09:20 +02:00
setMsg ( msgId , doneText , "ok" ) ;
return true ;
} catch ( e ) { setMsg ( msgId , "Failed: " + ( e . message || e ) , "err" ) ; push ( "error: " + ( e . message || e ) ) ; return false ; }
finally { btn . disabled = false ; btn . textContent = orig ; try { el ? . close ? . ( ) ; } catch { } }
}
$ ( "ct-save" ) . addEventListener ( "click" , ( ) => publishRecords ( collectContent ( ) , "ct-log" , "ct-msg" , "ct-save" , "Saved. Records refresh on the chain within a block." ) ) ;
$ ( "rd-save" ) . addEventListener ( "click" , ( ) => {
const u = $ ( "rd-url" ) . value . trim ( ) ;
if ( ! /^https?:\/\//i . test ( u ) ) return setMsg ( "rd-msg" , "Enter a full URL starting with http:// or https://" , "err" ) ;
publishRecords ( collectContent ( ) , "rd-log" , "rd-msg" , "rd-save" , "Redirect saved. Resolvers forward the name within a block." ) ;
} ) ;
$ ( "rd-clear" ) . addEventListener ( "click" , ( ) => { $ ( "rd-url" ) . value = "" ; publishRecords ( collectContent ( ) , "rd-log" , "rd-msg" , "rd-clear" , "Redirect removed." ) ; } ) ;
// ---------- transfer ----------
function validAddress ( a ) { try { BNS . addressToLockingBytecode ( a ) ; return true ; } catch { return false ; } }
function updateTransferBtn ( ) {
const a = $ ( "tr-addr" ) . value . trim ( ) ;
$ ( "tr-send" ) . disabled = ! ( $ ( "tr-ack" ) . checked && validAddress ( a ) && a !== wallet ? . address ) ;
}
$ ( "tr-addr" ) . addEventListener ( "input" , updateTransferBtn ) ;
$ ( "tr-ack" ) . addEventListener ( "change" , updateTransferBtn ) ;
$ ( "tr-send" ) . addEventListener ( "click" , async ( ) => {
if ( ! current . entry || ! wallet ) return ;
const to = $ ( "tr-addr" ) . value . trim ( ) ;
if ( wallet . source === "wc" ) return setMsg ( "tr-msg" , "Transfers from an external wallet land in the next drop — sign in with the built-in wallet." , "err" ) ;
if ( ! /^bchtest:/ . test ( to ) ) return setMsg ( "tr-msg" , "Chipnet addresses start with bchtest: — a mainnet address would lose the name." , "err" ) ;
if ( ! confirm ( ` Transfer ${ current . name } to ${ to } ? This cannot be undone. ` ) ) return ;
const push = logger ( "tr-log" ) ; const btn = $ ( "tr-send" ) ;
btn . disabled = true ; btn . textContent = "Transferring…" ; setMsg ( "tr-msg" , "" ) ;
let el = null ;
try {
el = await BNS . connect ( ) ; push ( "connected to chipnet" ) ;
const utxos = await BNS . getUtxosForAddresses ( el , wallet . watchedAddresses ) ;
const raw = BNS . normalizeName ( current . name ) ;
const commitment = [ ... new TextEncoder ( ) . encode ( raw ) ] . map ( ( b ) => b . toString ( 16 ) . padStart ( 2 , "0" ) ) . join ( "" ) ;
const cert = utxos . find ( ( u ) => u . token ? . category === current . entry . category && u . token ? . nft ? . commitment === commitment ) ;
if ( ! cert ) throw new Error ( "certificate UTXO not found in this wallet (already moved?)" ) ;
push ( "found certificate · building transfer" ) ;
const built = BNS . buildUpdateTx ( {
name : current . name , ownerAddress : wallet . address , ownerTokenAddress : BNS . toTokenAddress ( to ) ,
records : current . entry . records || { } , certificateUtxo : cert , utxos : utxos . filter ( ( u ) => ! u . token ) ,
addressToLockingBytecode : BNS . addressToLockingBytecode ,
} ) ;
const signed = BNS . signTransaction ( built . transaction , built . sourceOutputs , ( h ) => wallet . keyFor ( h ) ) ;
push ( "signed · broadcasting" ) ;
const txid = await BNS . broadcast ( el , signed . hex ) ;
push ( "broadcast: " + txid ) ;
setMsg ( "tr-msg" , ` Transferred. ${ current . name } now belongs to ${ to } . It disappears from this list after the next refresh. ` , "ok" ) ;
btn . textContent = "Transferred ✓" ;
setTimeout ( ( ) => loadHoldings ( ) , 4000 ) ;
} catch ( e ) { setMsg ( "tr-msg" , "Failed: " + ( e . message || e ) , "err" ) ; push ( "error: " + ( e . message || e ) ) ; btn . disabled = false ; btn . textContent = "Transfer name →" ; }
finally { try { el ? . close ? . ( ) ; } catch { } }
} ) ;
// ---------- export ----------
function zoneText ( ) {
const e = current . entry ; if ( ! e ) return "" ;
const c = dnsCache . get ( e . name ) ; const d = c ? . status === "ok" ? c . dns : { } ;
const L = [ ` ; ${ e . name } — exported from Sirius.X ${ new Date ( ) . toISOString ( ) } ` , ` $ ORIGIN ${ e . name } . ` , ` $ TTL 300 ` , ` ` ] ;
const rec = e . records || { } ;
L . push ( ` ; on-chain records (authoritative for BCNR resolvers) ` ) ;
for ( const k of [ "h" , "s3" , "p" , "ip" , "tls" , "u" ] ) if ( rec [ k ] ) L . push ( ` ; ${ k } = ${ String ( rec [ k ] ) . replace ( /\n/g , " " ) } ` ) ;
if ( rec . ip && ! ( Array . isArray ( d . A ) && d . A . length ) ) L . push ( ` @ \t IN \t A \t ${ rec . ip } ` ) ;
L . push ( ` ` , ` ; signed DNS manifest ${ c ? . seq ? ` (seq ${ c . seq } ) ` : "" } ` ) ;
for ( const v of d . A || [ ] ) L . push ( ` @ \t IN \t A \t ${ v } ` ) ;
for ( const v of d . AAAA || [ ] ) L . push ( ` @ \t IN \t AAAA \t ${ v } ` ) ;
if ( d . CNAME ) L . push ( ` @ \t IN \t CNAME \t ${ d . CNAME } . ` ) ;
for ( const m of d . MX || [ ] ) L . push ( ` @ \t IN \t MX \t ${ m . pref } \t ${ m . host } . ` ) ;
for ( const v of d . NS || [ ] ) L . push ( ` @ \t IN \t NS \t ${ v } . ` ) ;
for ( const v of d . TXT || [ ] ) L . push ( ` @ \t IN \t TXT \t " ${ String ( v ) . replace ( /"/g , '\\"' ) } " ` ) ;
for ( const s of d . SRV || [ ] ) L . push ( ` @ \t IN \t SRV \t ${ s . priority } ${ s . weight } ${ s . port } \t ${ s . target } . ` ) ;
for ( const x of d . CAA || [ ] ) L . push ( ` @ \t IN \t CAA \t ${ x . flags || 0 } ${ x . tag } " ${ x . value } " ` ) ;
return L . join ( "\n" ) + "\n" ;
}
function renderExportPreview ( ) { $ ( "ex-preview" ) . textContent = zoneText ( ) ; }
function download ( filename , text , type = "text/plain" ) {
const a = document . createElement ( "a" ) ;
a . href = URL . createObjectURL ( new Blob ( [ text ] , { type } ) ) ; a . download = filename ; a . click ( ) ;
setTimeout ( ( ) => URL . revokeObjectURL ( a . href ) , 1000 ) ;
}
$ ( "ex-zone" ) . addEventListener ( "click" , ( ) => download ( ` ${ current . name } .zone.txt ` , zoneText ( ) ) ) ;
$ ( "ex-json" ) . addEventListener ( "click" , ( ) => {
const c = dnsCache . get ( current . name ) ;
download ( ` ${ current . name } .json ` , JSON . stringify ( { name : current . name , chain : current . entry , dns : c ? . raw || null , exported _at : new Date ( ) . toISOString ( ) } , null , 2 ) , "application/json" ) ;
} ) ;
$ ( "ex-copy" ) . addEventListener ( "click" , ( ) => { const c = dnsCache . get ( current . name ) ; navigator . clipboard ? . writeText ( JSON . stringify ( c ? . raw || { dns : { } } , null , 2 ) ) ; } ) ;
// ---------- TLDs ----------
function isTldHidden ( records ) { const h = records ? . hidden ; return h === 1 || h === true || h === "1" ; }
function tldPriceOf ( records ) { const p = records ? . price ; return typeof p === "number" && Number . isFinite ( p ) && p >= 0 ? p : null ; }
const fmtUsd = ( n ) => ( window . siriusPricing ? . formatUsd || ( ( x ) => "$" + x ) ) ( n ) ;
function renderTlds ( ) {
const status = $ ( "tlds-status" ) , list = $ ( "tlds-list" ) ;
if ( ! H . tlds . length ) {
status . className = "status" ;
status . innerHTML = ` This wallet does not hold any TLD certificates yet. <a href="#newtld" data-go="newtld">Register one →</a> — every name registered under it earns you 90% of the service fee. ` ;
list . innerHTML = "" ; return ;
}
status . className = "status ok" ; status . textContent = ` You own ${ H . tlds . length } TLD ${ H . tlds . length === 1 ? "" : "s" } . ` ;
list . innerHTML = H . tlds . map ( ( t ) => {
const rec = t . records || { } ; const off = isTldHidden ( rec ) ; const price = tldPriceOf ( rec ) ;
const under = H . names . filter ( ( n ) => n . tld === t . tld ) . length ;
return ` <div class="drow" data-go="tld" data-arg=" ${ esc ( t . tld ) } ">
< div class = "n" > . < span class = "tld" > $ { esc ( t . tld ) } < / s p a n >
< span class = "badge ${off ? " b - off " : " b - ok "}" > $ { off ? "off · private" : "on · public" } < / s p a n >
< span class = "badge b-x" > $ { price != null ? ` ${ esc ( fmtUsd ( price ) ) } / name ` : "tier pricing" } < / s p a n > < / d i v >
< div class = "acts" > < button class = "btn small acid" data - go = "tld" data - arg = "${esc(t.tld)}" > Manage < / b u t t o n > < / d i v >
< div class = "meta" > < span > $ { under } of your names under it < / s p a n > < s p a n > · < / s p a n > < s p a n > p o l i c y $ { e s c ( r e c . p o l i c y | | " o p e n " ) } < / s p a n > < / d i v >
< / d i v > ` ;
} ) . join ( "" ) ;
}
async function registry ( ) {
if ( Date . now ( ) - registryCache . at < 60000 ) return registryCache . names ;
try {
const r = await fetch ( ` ${ API } /api/registry ` , { cache : "no-store" } ) ;
const j = await r . json ( ) ; registryCache = { at : Date . now ( ) , names : j . names || [ ] } ;
} catch { registryCache . at = Date . now ( ) ; }
return registryCache . names ;
}
async function openTld ( label ) {
const t = H . tlds . find ( ( x ) => x . tld === label ) ;
currentTld = t || null ;
$ ( "td-name" ) . textContent = "." + label ; $ ( "td-crumb" ) . textContent = "." + label ; $ ( "td-suffix" ) . textContent = "." + label ;
setMsg ( "td-msg" , "" ) ;
if ( ! t ) { $ ( "td-kv" ) . innerHTML = ` <div class="k">Status</div><div class="v">This wallet does not hold . ${ esc ( label ) } .</div> ` ; return ; }
const rec = t . records || { } ; const off = isTldHidden ( rec ) ; const price = tldPriceOf ( rec ) ;
const st = $ ( "td-state" ) ; st . className = "badge " + ( off ? "b-off" : "b-ok" ) ; st . textContent = off ? "off · private" : "on · public" ;
$ ( "td-price" ) . textContent = price != null ? ` ${ fmtUsd ( price ) } / name ` : "tier pricing" ;
$ ( "td-toggle" ) . textContent = off ? "Switch on" : "Switch off" ;
let pub = null ;
try { const j = await ( await fetch ( TLD _API , { cache : "no-store" } ) ) . json ( ) ; pub = ( j . tlds || [ ] ) . find ( ( x ) => x . tld === label ) || null ; } catch { }
const kv = [
[ "Certificate" , ` <code> ${ esc ( t . category ) } </code> ` ] ,
[ "Owner" , ` <code> ${ esc ( t . owner || wallet . address ) } </code> ` ] ,
[ "Registered" , t . reg _height ? ` block ${ fmtInt ( t . reg _height ) } ` : "mempool" ] ,
[ "Last policy update" , t . updated _txid ? ` <a href=" ${ txUrl ( t . updated _txid ) } " target="_blank" rel="noopener"> ${ esc ( t . updated _txid . slice ( 0 , 16 ) ) } … →</a> ` : "none" ] ,
[ "Names registered" , pub ? String ( pub . registered _count ) : "—" ] ,
[ "Price" , price != null ? ` ${ esc ( fmtUsd ( price ) ) } per name (you receive 90%) ` : "length tiers ($0.10 – $5)" ] ,
[ "Listing" , off ? "off — private: only you can register names" : "on — anyone can register names" ] ,
[ "Policy" , esc ( rec . policy || "open" ) + ( rec . min _len ? ` · min length ${ rec . min _len } ` : "" ) + ( rec . reserved ? ` · reserved: ${ esc ( rec . reserved ) } ` : "" ) ] ,
] ;
$ ( "td-kv" ) . innerHTML = kv . map ( ( [ k , v ] ) => ` <div class="k"> ${ k } </div><div class="v"> ${ v } </div> ` ) . join ( "" ) ;
const names = ( await registry ( ) ) . filter ( ( n ) => n . tld === label ) ;
$ ( "td-names" ) . innerHTML = names . length
? names . map ( ( n ) => ` <div class="drow" style="cursor:default"><div class="n"><span> ${ esc ( n . name ) } </span> ${ H . names . some ( ( m ) => m . name === n . name ) ? '<span class="badge b-x">yours</span>' : "" } </div>
< div class = "acts" > < a class = "btn small ghost" href = "${siteUrl(n.name)}" target = "_blank" rel = "noopener" > Open → < / a > < / d i v >
< div class = "meta" > < span > block $ { n . height ? fmtInt ( n . height ) : "mempool" } < / s p a n > < / d i v > < / d i v > ` ) . j o i n ( " " )
: ` <div class="empty">No names registered under . ${ esc ( label ) } yet.</div> ` ;
}
$ ( "td-register" ) . addEventListener ( "click" , ( ) => {
if ( ! currentTld ) return ;
const input = $ ( "td-label" ) ; const label = String ( input . value || "" ) . toLowerCase ( ) . replace ( /[^a-z0-9-]/g , "" ) ;
input . value = label ; if ( ! label ) return input . focus ( ) ;
if ( typeof window . siriusRegisterName === "function" ) window . siriusRegisterName ( ` ${ label } . ${ currentTld . tld } ` ) ;
} ) ;
$ ( "td-label" ) . addEventListener ( "keydown" , ( e ) => { if ( e . key === "Enter" ) $ ( "td-register" ) . click ( ) ; } ) ;
$ ( "td-toggle" ) . addEventListener ( "click" , async ( ) => {
if ( ! currentTld || ! wallet ) return ;
if ( wallet . source === "wc" ) return setMsg ( "td-msg" , "External-wallet (WizardConnect) TUPD lands in the next drop — please sign in with the built-in wallet." , "err" ) ;
const rec = { ... ( currentTld . records || { } ) } ; const turningOff = ! isTldHidden ( rec ) ;
if ( turningOff ) rec . hidden = 1 ; else delete rec . hidden ;
const btn = $ ( "td-toggle" ) ; btn . disabled = true ; btn . textContent = "Signing…" ;
let el = null ;
try {
el = await BNS . connect ( ) ;
const res = await BNS . setTldRecordsWithBuiltInWallet ( el , { wallet , tld : currentTld . tld , records : rec , category : currentTld . category , onProgress : ( ) => { } } ) ;
currentTld . records = rec ;
setMsg ( "td-msg" , ` ${ turningOff ? "Off" : "On" } — broadcast ${ ( res . txid || "" ) . slice ( 0 , 16 ) } …. The public registry picks it up within a minute. ` , "ok" ) ;
openTld ( currentTld . tld ) ; renderTlds ( ) ;
} catch ( e ) { setMsg ( "td-msg" , "Failed: " + ( e . message || e ) , "err" ) ; }
finally { btn . disabled = false ; try { el ? . close ? . ( ) ; } catch { } }
} ) ;
$ ( "td-policy" ) . addEventListener ( "click" , ( ) => { if ( currentTld ) openTldEditor ( currentTld . tld , currentTld . category , currentTld . records || { } ) ; } ) ;
// ---------- TLD policy editor (TUPD) ----------
const TLD _POLICY _FIELDS = [
{ key : "price" , id : "tld-ed-price" , parse : parseUsdOrNull } ,
{ key : "hidden" , id : "tld-ed-hidden" , parse : ( v ) => ( String ( v ) === "1" ? 1 : null ) } ,
{ key : "min_len" , id : "tld-ed-minlen" , parse : parseIntOrNull } ,
{ key : "fee_bps" , id : "tld-ed-feebps" , parse : parseIntOrNull } ,
{ key : "policy" , id : "tld-ed-policy" , parse : trimOrNull } ,
{ key : "reserved" , id : "tld-ed-reserved" , parse : trimOrNull } ,
{ key : "renewal_period" , id : "tld-ed-renewal" , parse : parseIntOrNull } ,
{ key : "renewal_fee" , id : "tld-ed-renewalfee" , parse : parseIntOrNull } ,
{ key : "site" , id : "tld-ed-site" , parse : trimOrNull } ,
] ;
function parseIntOrNull ( v ) { v = String ( v ) . trim ( ) ; if ( ! v ) return null ; const n = Number ( v ) ; return Number . isFinite ( n ) && n >= 0 ? Math . floor ( n ) : null ; }
function parseUsdOrNull ( v ) { v = String ( v ) . trim ( ) . replace ( /^\$/ , "" ) ; if ( ! v ) return null ; const n = Number ( v ) ; return Number . isFinite ( n ) && n >= 0 ? Math . round ( n * 100 ) / 100 : null ; }
function trimOrNull ( v ) { v = String ( v ) . trim ( ) ; return v || null ; }
let tldEditor = { tld : null , category : null } ;
function openTldEditor ( tld , category , records ) {
tldEditor = { tld , category } ;
$ ( "tld-ed-name" ) . textContent = "." + tld ;
for ( const f of TLD _POLICY _FIELDS ) { const cur = records ? . [ f . key ] ; $ ( f . id ) . value = cur == null ? "" : String ( cur ) ; }
setMsg ( "tld-ed-msg" , "" ) ; $ ( "tld-ed-log" ) . className = "steps-log" ; $ ( "tld-ed-log" ) . innerHTML = "" ;
$ ( "tld-ed-save" ) . disabled = false ; $ ( "tld-ed-save" ) . textContent = "Save policy →" ;
updateTldBudget ( ) ; $ ( "tld-editor" ) . classList . add ( "open" ) ;
}
function closeTldEditor ( ) { $ ( "tld-editor" ) . classList . remove ( "open" ) ; tldEditor = { tld : null , category : null } ; }
$ ( "tld-ed-close" ) . addEventListener ( "click" , closeTldEditor ) ;
$ ( "tld-ed-cancel" ) . addEventListener ( "click" , closeTldEditor ) ;
$ ( "tld-editor" ) . addEventListener ( "click" , ( e ) => { if ( e . target === $ ( "tld-editor" ) ) closeTldEditor ( ) ; } ) ;
function collectTldRecords ( ) { const r = { } ; for ( const f of TLD _POLICY _FIELDS ) { const v = f . parse ( $ ( f . id ) . value ) ; if ( v !== null && v !== undefined && v !== "" ) r [ f . key ] = v ; } return r ; }
function updateTldBudget ( ) {
if ( ! tldEditor . tld ) return ;
try {
const max = BNS . payloadBudget ( tldEditor . tld , "TUPD" ) ;
const used = new TextEncoder ( ) . encode ( JSON . stringify ( collectTldRecords ( ) ) ) . length ;
$ ( "tld-ed-budget-max" ) . textContent = max ; $ ( "tld-ed-budget-used" ) . textContent = used ;
$ ( "tld-ed-budget" ) . classList . toggle ( "over" , used > max ) ; $ ( "tld-ed-save" ) . disabled = used > max ;
} catch { }
}
for ( const f of TLD _POLICY _FIELDS ) $ ( f . id ) . addEventListener ( "input" , updateTldBudget ) ;
$ ( "tld-ed-save" ) . addEventListener ( "click" , async ( ) => {
if ( ! tldEditor . tld || ! tldEditor . category || ! wallet ) return ;
if ( wallet . source === "wc" ) return setMsg ( "tld-ed-msg" , "External-wallet (WizardConnect) TUPD lands in the next drop — please sign in with the built-in wallet." , "err" ) ;
const records = collectTldRecords ( ) ; const push = logger ( "tld-ed-log" ) ;
$ ( "tld-ed-save" ) . disabled = true ; $ ( "tld-ed-save" ) . textContent = "Signing…" ; setMsg ( "tld-ed-msg" , "" ) ;
let el = null ;
try {
el = await BNS . connect ( ) ; push ( "connected to chipnet" ) ;
const res = await BNS . setTldRecordsWithBuiltInWallet ( el , { wallet , tld : tldEditor . tld , records , category : tldEditor . category , onProgress : ( s ) => push ( String ( s ) ) } ) ;
push ( "broadcast: " + ( res . txid || "(no txid)" ) ) ;
const t = H . tlds . find ( ( x ) => x . tld === tldEditor . tld ) ; if ( t ) t . records = records ;
setMsg ( "tld-ed-msg" , "Policy saved. It reflects in registrations under this TLD within a block." , "ok" ) ;
$ ( "tld-ed-save" ) . textContent = "Saved ✓" ;
renderTlds ( ) ; if ( currentTld ? . tld === tldEditor . tld ) openTld ( tldEditor . tld ) ;
setTimeout ( closeTldEditor , 1200 ) ;
} catch ( e ) { setMsg ( "tld-ed-msg" , "Failed: " + ( e . message || e ) , "err" ) ; push ( "error: " + ( e . message || e ) ) ; $ ( "tld-ed-save" ) . disabled = false ; $ ( "tld-ed-save" ) . textContent = "Save policy →" ; }
finally { try { el ? . close ? . ( ) ; } catch { } }
} ) ;
// ---------- new TLD ----------
const CHIPNET _SATS _PER _USD = 250_000 ;
function usdToSats ( usd ) { const n = Number ( String ( usd ) . replace ( /[^\d.]/g , "" ) ) || 0 ; return Math . max ( 0 , Math . round ( n * CHIPNET _SATS _PER _USD ) ) ; }
function updateTldPrice ( ) {
const sats = usdToSats ( $ ( "tld-usd" ) . value . trim ( ) || "0" ) ;
$ ( "tld-price" ) . innerHTML = ` = <b> ${ sats . toLocaleString ( ) } </b> sats · rate <b> ${ CHIPNET _SATS _PER _USD . toLocaleString ( ) } </b> sats/USD (chipnet) ` ;
$ ( "tld-submit" ) . textContent = ` Register (~ ${ sats . toLocaleString ( ) } sats) ` ;
}
const validateTldLabel = ( l ) => / ^ [ a - z0 - 9 - ] { 1 , 16 } $ / . test ( l ) ;
function updateTldButtons ( ) { const ok = validateTldLabel ( $ ( "tld-label" ) . value . trim ( ) . toLowerCase ( ) ) ; $ ( "tld-check" ) . disabled = ! ok || ! wallet ; $ ( "tld-submit" ) . disabled = ! ok || ! wallet ; }
$ ( "tld-label" ) . addEventListener ( "input" , ( ) => { $ ( "tld-label" ) . value = $ ( "tld-label" ) . value . toLowerCase ( ) . replace ( /[^a-z0-9-]/g , "" ) . slice ( 0 , 16 ) ; updateTldButtons ( ) ; } ) ;
$ ( "tld-usd" ) . addEventListener ( "input" , updateTldPrice ) ;
$ ( "tld-check" ) . addEventListener ( "click" , async ( ) => {
const label = $ ( "tld-label" ) . value . trim ( ) ; const msg = $ ( "tld-msg" ) ; msg . textContent = "" ;
try {
const j = await ( await fetch ( TLD _API ) ) . json ( ) ;
const taken = ( j . tlds || [ ] ) . some ( ( t ) => ( t . tld || t ) === label ) ;
msg . textContent = taken ? ` ❌ . ${ label } is already registered ` : ` ✅ . ${ label } is available ` ;
msg . style . color = taken ? "var(--taken)" : "var(--ok)" ;
} catch ( e ) { msg . textContent = "could not check: " + ( e . message || e ) ; msg . style . color = "var(--warn)" ; }
} ) ;
$ ( "tld-submit" ) . addEventListener ( "click" , async ( ) => {
if ( ! wallet ) return ;
const label = $ ( "tld-label" ) . value . trim ( ) ; if ( ! validateTldLabel ( label ) ) return ;
const sats = BigInt ( usdToSats ( $ ( "tld-usd" ) . value . trim ( ) || "0" ) ) ;
const msg = $ ( "tld-msg" ) ; msg . textContent = "" ;
const log = $ ( "tld-log" ) ; log . style . display = "block" ; log . innerHTML = "" ;
const push = ( t ) => { const d = document . createElement ( "div" ) ; d . textContent = "› " + t ; log . appendChild ( d ) ; log . scrollTop = log . scrollHeight ; } ;
$ ( "tld-submit" ) . disabled = true ; $ ( "tld-submit" ) . textContent = "Signing…" ;
let el = null ;
try {
el = await BNS . connect ( ) ; push ( "connected to chipnet" ) ;
const serviceFee = { address : BNS . REGISTRAR ? . serviceFee ? . address ? ? null , sats } ;
const args = { tld : label , records : { } , serviceFee , tldListUrl : "https://navigate.st/api/tlds" , onProgress : ( s ) => push ( s ) } ;
const res = wallet . source === "wc"
? await BNS . registerTldWithExternalWallet ( el , { session : wallet . session , ... args } )
: await BNS . registerTldWithBuiltInWallet ( el , { wallet , ... args } ) ;
push ( "broadcast: " + ( res . txid || res . txId || "(no txid)" ) ) ;
msg . style . color = "var(--ok)" ; msg . textContent = ` ✅ . ${ label } minted. Category ${ ( res . category || "" ) . slice ( 0 , 16 ) } … ` ;
$ ( "tld-submit" ) . textContent = "Registered ✓" ;
setTimeout ( ( ) => loadHoldings ( ) , 2500 ) ;
} catch ( e ) {
msg . style . color = "var(--taken)" ; const raw = String ( e . message || e ) ;
msg . innerHTML = /wallet is empty/i . test ( raw )
? ` Failed: wallet is empty. Fund <code style="font-size:11.5px"> ${ esc ( wallet . address ) } </code> with chipnet coins first — free from <a href="https://tbch.googol.cash/" target="_blank" rel="noopener">tbch.googol.cash</a>. `
: "Failed: " + esc ( raw ) ;
push ( "error: " + raw ) ; $ ( "tld-submit" ) . disabled = false ; updateTldPrice ( ) ;
} finally { try { el ? . close ? . ( ) ; } catch { } }
} ) ;
updateTldPrice ( ) ;
// ---------- settings ----------
function paintSettings ( ) {
const S = window . siriusSession , P = window . siriusPin ;
$ ( "set-stay" ) . checked = ! ! S ? . isEnabled ? . ( ) ;
$ ( "set-pinpay" ) . checked = ! ! S ? . requirePinForPayments ? . ( ) ;
const hasPin = ! ! P ? . hasPin ? . ( ) ;
$ ( "set-pinstate" ) . textContent = hasPin ? "A PIN is set on this device." : "No PIN set." ;
$ ( "set-clearpin" ) . disabled = ! hasPin ;
$ ( "set-setpin" ) . disabled = ! wallet ? . mnemonic ;
}
$ ( "set-stay" ) . addEventListener ( "change" , async ( e ) => {
const S = window . siriusSession ; if ( ! S ) return ;
S . setEnabled ( e . target . checked ) ;
if ( e . target . checked && wallet ? . mnemonic ) await S . remember ( wallet . mnemonic , { accountPath : wallet . accountPath } ) ;
setMsg ( "set-msg" , e . target . checked ? "This device stays signed in." : "You will be asked to unlock on your next visit." , "ok" ) ;
} ) ;
$ ( "set-pinpay" ) . addEventListener ( "change" , ( e ) => { window . siriusSession ? . setRequirePinForPayments ( e . target . checked ) ; setMsg ( "set-msg" , e . target . checked ? "Payments will ask for your PIN. Set one below if you have none." : "Payments approve with one click." , "ok" ) ; } ) ;
$ ( "set-setpin" ) . addEventListener ( "click" , ( ) => { $ ( "pin-1" ) . value = "" ; $ ( "pin-2" ) . value = "" ; setMsg ( "pin-msg" , "" ) ; $ ( "pin-modal" ) . classList . add ( "open" ) ; $ ( "pin-1" ) . focus ( ) ; } ) ;
const closePin = ( ) => $ ( "pin-modal" ) . classList . remove ( "open" ) ;
$ ( "pin-close" ) . addEventListener ( "click" , closePin ) ; $ ( "pin-cancel" ) . addEventListener ( "click" , closePin ) ;
$ ( "pin-modal" ) . addEventListener ( "click" , ( e ) => { if ( e . target === $ ( "pin-modal" ) ) closePin ( ) ; } ) ;
$ ( "pin-save" ) . addEventListener ( "click" , async ( ) => {
const a = $ ( "pin-1" ) . value , b = $ ( "pin-2" ) . value ;
if ( ! /^\d{4,6}$/ . test ( a ) ) return setMsg ( "pin-msg" , "PIN must be 4– 6 digits" , "err" ) ;
if ( a !== b ) return setMsg ( "pin-msg" , "PINs do not match" , "err" ) ;
try { await window . siriusPin . savePinBlob ( wallet . mnemonic , a ) ; closePin ( ) ; paintSettings ( ) ; setMsg ( "set-msg" , "PIN saved." , "ok" ) ; }
catch ( e ) { setMsg ( "pin-msg" , e . message || String ( e ) , "err" ) ; }
} ) ;
$ ( "set-clearpin" ) . addEventListener ( "click" , ( ) => { window . siriusPin ? . clear ? . ( ) ; paintSettings ( ) ; setMsg ( "set-msg" , "PIN removed." , "ok" ) ; } ) ;