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
// Sirius Studio — a site builder for BCNR names.
//
// GrapesJS (BSD-3, vendored under ./vendor/grapesjs/) edits the page. On
// Publish the exported HTML+CSS becomes index.html in the name's own folder
// on Sia — bns/<name>/ — uploaded through the gateway's /api/site route,
// every request signed with the wallet key that holds the name's NFT. The
// editor's project data is saved next to it (_studio.json) so the site can
// be reopened and edited later. If the name's on-chain s3 record does not
// point at that folder yet, Publish also sends one UPD that sets it.
//
// The gateway never holds a key: it verifies each upload's signature
// against the current NFT owner and refuses writes outside bns/<name>/.
2026-09-17 01:40:10 +02:00
import * as BNS from "https://silentmode.st/js/bns-register.js?v=20260917sig" ;
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 API = "https://silentmode.st" ;
const $ = ( id ) => document . getElementById ( id ) ;
const esc = ( s ) => String ( s ? ? "" ) . replace ( /[&<>"']/g , ( c ) => ( { "&" : "&" , "<" : "<" , ">" : ">" , '"' : """ , "'" : "'" } [ c ] ) ) ;
const name = ( new URLSearchParams ( location . search ) . get ( "name" ) || "" ) . toLowerCase ( ) . trim ( ) ;
const folder = name ? ` bns/ ${ name } / ` : null ;
const readUrl = ( path ) => ` ${ API } /api/site/ ${ encodeURIComponent ( name ) } / ${ path } ` ;
const siteUrl = ( ) => ` ${ API } /bns/ ${ encodeURIComponent ( name ) } / ` ;
let wallet = null ;
let entry = null ; // { records, category, ... } from /api/name
let editor = null ;
let dirty = false ;
function status ( text , cls = "" ) { const el = $ ( "status" ) ; el . textContent = text ; el . className = "st " + cls ; }
// ---------- session ----------
async function restoreWallet ( ) {
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 ;
return await BNS . BuiltInWallet . fromMnemonic ( s . mnemonic , BNS . CHIPNET _PREFIX , s . accountPath || undefined ) ;
} catch { return null ; }
}
// ---------- signed uploads ----------
const hex = ( buf ) => [ ... new Uint8Array ( buf ) ] . map ( ( b ) => b . toString ( 16 ) . padStart ( 2 , "0" ) ) . join ( "" ) ;
async function signedHeaders ( path , bodyBytes ) {
const ts = String ( Date . now ( ) ) ;
const bodyHash = hex ( await crypto . subtle . digest ( "SHA-256" , bodyBytes ) ) ;
const msg = ` BNS-SITE1 \n ${ name } \n ${ path } \n ${ bodyHash } \n ${ ts } ` ;
const digest = new Uint8Array ( await crypto . subtle . digest ( "SHA-256" , new TextEncoder ( ) . encode ( msg ) ) ) ;
const sig = wallet . signMessage ( digest ) ;
return { "x-bns-ts" : ts , "x-bns-sig" : btoa ( String . fromCharCode ( ... sig ) ) } ;
}
async function putFile ( path , body , contentType ) {
const bytes = body instanceof Uint8Array ? body : new Uint8Array ( await new Blob ( [ body ] ) . arrayBuffer ( ) ) ;
const headers = { "content-type" : contentType , ... ( await signedHeaders ( path , bytes ) ) } ;
const r = await fetch ( readUrl ( path ) , { method : "PUT" , headers , body : bytes } ) ;
const j = await r . json ( ) . catch ( ( ) => ( { } ) ) ;
if ( ! r . ok ) throw new Error ( ` ${ path } : ${ j . error || r . status } ` ) ;
return j ;
}
async function listFiles ( ) {
const r = await fetch ( ` ${ API } /api/site/ ${ encodeURIComponent ( name ) } ` , { cache : "no-store" } ) ;
if ( ! r . ok ) return [ ] ;
const j = await r . json ( ) . catch ( ( ) => ( { } ) ) ;
return j . files || [ ] ;
}
// ---------- templates ----------
const TEMPLATES = {
blank : { html : ` <section style="padding:60px 20px;text-align:center"><h1> ${ esc ( name ) } </h1><p>Start building.</p></section> ` , css : ` body{font-family:system-ui,sans-serif;margin:0;color:#111} ` } ,
landing : {
html : `
< header class = "hero" >
< h1 > Hello from $ { esc ( name ) } < / h 1 >
< p > A name on the Bitcoin Cash chain , a site on Sia . No host , no renewal , no permission . < / p >
< a class = "cta" href = "#more" > Learn more < / a >
< / h e a d e r >
< section id = "more" class = "features" >
< div class = "f" > < h3 > Yours < / h 3 > < p > T h e c e r t i f i c a t e s i t s i n y o u r w a l l e t . N o b o d y c a n t a k e i t b a c k . < / p > < / d i v >
< div class = "f" > < h3 > Fast < / h 3 > < p > S e r v e d f r o m S i a t h r o u g h a n y B C N R r e s o l v e r o r t h e p u b l i c g a t e w a y . < / p > < / d i v >
< div class = "f" > < h3 > Simple < / h 3 > < p > E d i t t h i s p a g e i n S i r i u s S t u d i o a n d p u b l i s h i n o n e c l i c k . < / p > < / d i v >
< / s e c t i o n >
< footer class = "foot" > © $ { new Date ( ) . getFullYear ( ) } $ { esc ( name ) } · built with Sirius Studio < / f o o t e r > ` ,
css : `
body { margin : 0 ; font - family : system - ui , - apple - system , Segoe UI , Roboto , sans - serif ; color : # 12161 f ; background : # fff }
. hero { padding : 96 px 24 px 72 px ; text - align : center ; background : linear - gradient ( 180 deg , # 0b0 e14 , # 141 a24 ) ; color : # f1f4fa }
. hero h1 { font - size : 44 px ; margin : 0 0 12 px ; letter - spacing : - . 01 em }
. hero p { font - size : 18 px ; color : # b8c2d4 ; max - width : 560 px ; margin : 0 auto 26 px }
. cta { display : inline - block ; background : # d6ff3d ; color : # 0b0 e14 ; font - weight : 700 ; padding : 12 px 22 px ; border - radius : 10 px ; text - decoration : none }
. features { display : grid ; grid - template - columns : repeat ( auto - fit , minmax ( 220 px , 1 fr ) ) ; gap : 20 px ; max - width : 960 px ; margin : 0 auto ; padding : 56 px 24 px }
. f { background : # f5f7fb ; border - radius : 14 px ; padding : 22 px }
. f h3 { margin : 0 0 8 px }
. f p { margin : 0 ; color : # 4 a5568 }
. foot { text - align : center ; padding : 28 px ; color : # 6 a7488 ; font - size : 13 px ; border - top : 1 px solid # e6e9f0 } ` ,
} ,
profile : {
html : `
< main class = "card" >
< div class = "avatar" > ★ < / d i v >
< h1 > $ { esc ( name ) } < / h 1 >
< p class = "bio" > One line about you . Edit me . < / p >
< a class = "link" href = "https://" > Website < / a >
< a class = "link" href = "https://" > Nostr < / a >
< a class = "link" href = "https://" > Bitcoin Cash tips < / a >
< a class = "link" href = "mailto:" > Email < / a >
< / m a i n > ` ,
css : `
body { margin : 0 ; min - height : 100 vh ; display : flex ; align - items : center ; justify - content : center ; background : radial - gradient ( circle at 50 % 0 , # 1 a2233 , # 050810 ) ; font - family : system - ui , sans - serif ; color : # f1f4fa }
. card { width : min ( 420 px , 92 vw ) ; text - align : center ; padding : 36 px 24 px }
. avatar { width : 88 px ; height : 88 px ; border - radius : 50 % ; background : # d6ff3d ; color : # 0b0 e14 ; font - size : 40 px ; line - height : 88 px ; margin : 0 auto 14 px }
h1 { margin : 0 0 6 px ; font - size : 26 px }
. bio { color : # b8c2d4 ; margin : 0 0 22 px }
. link { display : block ; background : # 141 a24 ; border : 1 px solid rgba ( 255 , 255 , 255 , . 1 ) ; color : # f1f4fa ; text - decoration : none ; padding : 14 px ; border - radius : 12 px ; margin : 10 px 0 ; font - weight : 600 }
. link : hover { border - color : # d6ff3d } ` ,
} ,
business : {
html : `
< nav class = "nav" > < b > $ { esc ( name ) } < / b > < s p a n > < a h r e f = " # a b o u t " > A b o u t < / a > < a h r e f = " # s e r v i c e s " > S e r v i c e s < / a > < a h r e f = " # c o n t a c t " > C o n t a c t < / a > < / s p a n > < / n a v >
< header class = "top" > < h1 > We are open < / h 1 > < p > S a y w h a t y o u d o i n o n e s e n t e n c e . < / p > < / h e a d e r >
< section id = "about" class = "sec" > < h2 > About < / h 2 > < p > T w o o r t h r e e s e n t e n c e s a b o u t t h e b u s i n e s s , t h e p e o p l e a n d t h e p l a c e . < / p > < / s e c t i o n >
< section id = "services" class = "sec alt" > < h2 > Services < / h 2 >
< ul class = "grid" > < li > < b > Service one < / b > < s p a n > S h o r t d e s c r i p t i o n . < / s p a n > < / l i > < l i > < b > S e r v i c e t w o < / b > < s p a n > S h o r t d e s c r i p t i o n . < / s p a n > < / l i > < l i > < b > S e r v i c e t h r e e < / b > < s p a n > S h o r t d e s c r i p t i o n . < / s p a n > < / l i > < / u l > < / s e c t i o n >
< section id = "contact" class = "sec" > < h2 > Contact < / h 2 > < p > S t r e e t 1 , C i t y · M o n – F r i 9 – 1 8 · < a h r e f = " m a i l t o : " > h e l l o @ e x a m p l e < / a > < / p > < / s e c t i o n >
< footer class = "foot" > © $ { new Date ( ) . getFullYear ( ) } $ { esc ( name ) } < / f o o t e r > ` ,
css : `
body { margin : 0 ; font - family : Georgia , serif ; color : # 2 b1d12 ; background : # fff8f0 }
. nav { display : flex ; justify - content : space - between ; align - items : center ; padding : 16 px 28 px ; border - bottom : 1 px solid # eadfd0 ; font - family : system - ui , sans - serif }
. nav a { margin - left : 18 px ; color : # 7 a3b00 ; text - decoration : none }
. top { text - align : center ; padding : 80 px 24 px ; background : # 7 a3b00 ; color : # fff4e6 }
. top h1 { font - size : 42 px ; margin : 0 0 10 px }
. sec { max - width : 820 px ; margin : 0 auto ; padding : 48 px 24 px }
. sec . alt { max - width : none ; background : # fff1e0 }
. sec . alt h2 , . sec . alt ul { max - width : 820 px ; margin - left : auto ; margin - right : auto }
. grid { list - style : none ; padding : 0 ; display : grid ; grid - template - columns : repeat ( auto - fit , minmax ( 200 px , 1 fr ) ) ; gap : 16 px }
. grid li { background : # fff ; border - radius : 12 px ; padding : 18 px ; box - shadow : 0 2 px 10 px rgba ( 0 , 0 , 0 , . 05 ) }
. grid b { display : block ; margin - bottom : 6 px }
. foot { text - align : center ; padding : 24 px ; color : # 8 a6d55 ; font - size : 13 px } ` ,
} ,
} ;
// ---------- editor ----------
function plugin ( nameOrObj ) {
const p = window [ nameOrObj ] ;
return p && ( p . default || p ) ;
}
function initEditor ( ) {
const plugins = [ ] ;
const pluginsOpts = { } ;
const basic = plugin ( "grapesjs-blocks-basic" ) ; if ( basic ) { plugins . push ( basic ) ; pluginsOpts [ basic ] = { flexGrid : true } ; }
const preset = plugin ( "grapesjs-preset-webpage" ) ; if ( preset ) { plugins . push ( preset ) ; pluginsOpts [ preset ] = { modalImportTitle : "Import HTML" , blocks : [ ] } ; }
editor = grapesjs . init ( {
container : "#gjs" ,
height : "100%" ,
fromElement : false ,
storageManager : false ,
plugins ,
pluginsOpts ,
canvas : { styles : [ ] } ,
assetManager : {
upload : false ,
uploadFile : async ( e ) => {
const files = e . dataTransfer ? e . dataTransfer . files : e . target . files ;
for ( const f of files ) await uploadAsset ( f ) ;
} ,
} ,
} ) ;
editor . on ( "update" , ( ) => { dirty = true ; $ ( "btn-draft" ) . disabled = false ; } ) ;
// Ctrl/Cmd+S saves a draft.
document . addEventListener ( "keydown" , ( e ) => { if ( ( e . ctrlKey || e . metaKey ) && e . key === "s" ) { e . preventDefault ( ) ; saveDraft ( ) ; } } ) ;
window . addEventListener ( "beforeunload" , ( e ) => { if ( dirty ) { e . preventDefault ( ) ; e . returnValue = "" ; } } ) ;
}
function loadTemplate ( key ) {
const t = TEMPLATES [ key ] || TEMPLATES . blank ;
editor . setComponents ( t . html ) ;
editor . setStyle ( t . css ) ;
dirty = true ; $ ( "btn-draft" ) . disabled = false ;
}
async function uploadAsset ( file ) {
const safe = file . name . toLowerCase ( ) . replace ( /[^a-z0-9._-]/g , "-" ) . replace ( /-+/g , "-" ) ;
const path = ` assets/ ${ Date . now ( ) . toString ( 36 ) } - ${ safe } ` ;
status ( ` Uploading ${ file . name } … ` , "busy" ) ;
try {
const bytes = new Uint8Array ( await file . arrayBuffer ( ) ) ;
await putFile ( path , bytes , file . type || "application/octet-stream" ) ;
editor . AssetManager . add ( { src : readUrl ( path ) , name : file . name , type : "image" } ) ;
status ( ` Uploaded ${ file . name } ` , "ok" ) ;
} catch ( e ) { status ( "Upload failed: " + ( e . message || e ) , "err" ) ; }
}
// Exported page: inline CSS, asset URLs rewritten to be relative to the
// site folder so the page works from Sia, any resolver and the gateway.
function exportHtml ( ) {
const html = editor . getHtml ( ) ;
const css = editor . getCss ( ) ;
const rel = ( s ) => s . split ( readUrl ( "" ) ) . join ( "" ) ;
return ` <!doctype html>
< html lang = "${esc(document.documentElement.lang || " en ")}" >
< head >
< meta charset = "utf-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1" >
< title > $ { esc ( name ) } < / t i t l e >
< meta name = "generator" content = "Sirius Studio" >
< style >
$ { rel ( css ) }
< / s t y l e >
< / h e a d >
$ { rel ( html ) }
< / h t m l >
` ;
}
// ---------- draft / publish ----------
function pushStep ( t ) { const d = document . createElement ( "div" ) ; d . textContent = t ; $ ( "pub-steps" ) . appendChild ( d ) ; $ ( "pub-steps" ) . scrollTop = 1e6 ; }
async function saveDraft ( ) {
if ( ! editor || ! wallet ) return ;
status ( "Saving draft…" , "busy" ) ;
try {
const data = JSON . stringify ( { v : 1 , name , saved _at : new Date ( ) . toISOString ( ) , project : editor . getProjectData ( ) } ) ;
await putFile ( "_studio.json" , data , "application/json" ) ;
dirty = false ; $ ( "btn-draft" ) . disabled = true ;
status ( "Draft saved on Sia" , "ok" ) ;
} catch ( e ) { status ( "Draft not saved: " + ( e . message || e ) , "err" ) ; }
}
async function publish ( ) {
if ( ! editor || ! wallet ) return ;
$ ( "pub-name" ) . textContent = name ; $ ( "pub-steps" ) . innerHTML = "" ; $ ( "pub-view" ) . hidden = true ; $ ( "pub" ) . hidden = false ;
$ ( "btn-publish" ) . disabled = true ;
try {
const html = exportHtml ( ) ;
pushStep ( ` exported page · ${ html . length . toLocaleString ( ) } bytes ` ) ;
const res = await putFile ( "index.html" , html , "text/html; charset=utf-8" ) ;
pushStep ( ` uploaded index.html → ${ res . sia _key } ` ) ;
const data = JSON . stringify ( { v : 1 , name , saved _at : new Date ( ) . toISOString ( ) , project : editor . getProjectData ( ) } ) ;
await putFile ( "_studio.json" , data , "application/json" ) ;
pushStep ( "saved editor project (_studio.json)" ) ;
dirty = false ; $ ( "btn-draft" ) . disabled = true ;
// Point the name at the folder if it does not already.
const rec = entry ? . records || { } ;
if ( rec . s3 !== folder ) {
pushStep ( ` name points at ${ rec . s3 ? ` " ${ rec . s3 } " ` : "nothing" } — setting s3 = ${ folder } on chain ` ) ;
const next = { ... rec , s3 : folder } ;
delete next . h ; // inline HTML would shadow the Sia site
let el = null ;
try {
el = await BNS . connect ( ) ;
const r = await BNS . setRecordsWithBuiltInWallet ( el , { wallet , name , records : next , onProgress : ( s ) => pushStep ( String ( s ) ) } ) ;
pushStep ( ` broadcast ${ r . txid || r . txId || "" } ` ) ;
entry = { ... ( entry || { } ) , records : next } ;
} finally { try { el ? . close ? . ( ) ; } catch { } }
pushStep ( "resolvers switch to the new site within a block" ) ;
} else {
pushStep ( "name already points at this folder — live now" ) ;
}
$ ( "pub-view" ) . href = siteUrl ( ) ; $ ( "pub-view" ) . hidden = false ;
$ ( "btn-view" ) . href = siteUrl ( ) ; $ ( "btn-view" ) . hidden = false ;
status ( "Published" , "ok" ) ;
} catch ( e ) {
pushStep ( "error: " + ( e . message || e ) ) ;
status ( "Publish failed" , "err" ) ;
} finally { $ ( "btn-publish" ) . disabled = false ; }
}
// ---------- boot ----------
( async function boot ( ) {
if ( ! name ) { location . replace ( "./portal.html#studio" ) ; return ; }
$ ( "site-name" ) . innerHTML = ` ${ esc ( name . split ( "." ) [ 0 ] ) } .<span class="tld"> ${ esc ( name . split ( "." ) . slice ( 1 ) . join ( "." ) ) } </span> ` ;
$ ( "picker-name" ) . textContent = name ;
document . title = ` ${ name } — Sirius Studio ` ;
wallet = await restoreWallet ( ) ;
if ( ! wallet ) { $ ( "gate-link" ) . href = ` ./portal.html?next= ${ encodeURIComponent ( location . pathname + location . search ) } ` ; $ ( "gate" ) . hidden = false ; status ( "Not signed in" , "err" ) ; return ; }
status ( "Loading name…" ) ;
try { const r = await fetch ( ` ${ API } /api/name/ ${ encodeURIComponent ( name ) } ` , { cache : "no-store" } ) ; entry = r . ok ? await r . json ( ) : null ; } catch { entry = null ; }
initEditor ( ) ;
$ ( "btn-publish" ) . disabled = false ;
if ( entry ? . records ? . s3 === folder ) { $ ( "btn-view" ) . href = siteUrl ( ) ; $ ( "btn-view" ) . hidden = false ; }
// Existing project?
const files = await listFiles ( ) ;
const hasProject = files . some ( ( f ) => f . path === "_studio.json" ) ;
if ( hasProject ) {
try {
const j = await ( await fetch ( readUrl ( "_studio.json" ) , { cache : "no-store" } ) ) . json ( ) ;
if ( j ? . project ) { editor . loadProjectData ( j . project ) ; dirty = false ; status ( ` Loaded draft from ${ j . saved _at ? new Date ( j . saved _at ) . toLocaleString ( ) : "Sia" } ` , "ok" ) ; }
} catch ( e ) { status ( "Could not load the saved project: " + ( e . message || e ) , "err" ) ; $ ( "picker" ) . hidden = false ; }
} else if ( files . some ( ( f ) => f . path === "index.html" ) ) {
// A site exists but was not made here — import its HTML as a starting point.
try {
const html = await ( await fetch ( readUrl ( "index.html" ) , { cache : "no-store" } ) ) . text ( ) ;
editor . setComponents ( html ) ; status ( "Imported existing index.html" , "ok" ) ;
} catch { $ ( "picker" ) . hidden = false ; }
} else {
$ ( "picker" ) . hidden = false ; status ( "New site" ) ;
}
} ) ( ) ;
$ ( "picker" ) . addEventListener ( "click" , ( e ) => {
const b = e . target . closest ( "[data-tpl]" ) ; if ( ! b ) return ;
loadTemplate ( b . dataset . tpl ) ; $ ( "picker" ) . hidden = true ; status ( "Template loaded — edit, then Publish" ) ;
} ) ;
$ ( "tpl-select" ) . addEventListener ( "change" , ( e ) => {
const v = e . target . value ; e . target . value = "" ;
if ( ! v ) return ;
if ( ! confirm ( "Replace the current page with this template?" ) ) return ;
loadTemplate ( v ) ;
} ) ;
$ ( "btn-preview" ) . addEventListener ( "click" , ( ) => {
const w = window . open ( "" , "_blank" ) ; if ( ! w ) return ;
w . document . open ( ) ; w . document . write ( exportHtml ( ) ) ; w . document . close ( ) ;
} ) ;
$ ( "btn-draft" ) . addEventListener ( "click" , saveDraft ) ;
$ ( "btn-publish" ) . addEventListener ( "click" , publish ) ;
$ ( "pub-close" ) . addEventListener ( "click" , ( ) => { $ ( "pub" ) . hidden = true ; } ) ;