From b9b7a4e5bd7140dc530dc00057c5ad53b0e24826 Mon Sep 17 00:00:00 2001 From: Silent Mode Date: Mon, 28 Sep 2026 01:21:47 +0200 Subject: [PATCH] feat(portal): the name's settings show what its subdomains serve, and let you change it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-name tab was a DNS record table: eight record types, of which the stack reads exactly one, and then only as a last resort when a name carries no content record. Everything else was signed, stored and read by nobody — furniture that looked like configuration. Meanwhile the thing a name owner actually has, a folder tree that already answers on matching subdomains, was not visible anywhere and could not be changed. The tab now leads with what answers today: the apex, every folder in the name's storage next to the subdomain it serves on, and which of those a rule overrides. Below it, the rules — point a subdomain at a different folder, redirect it, or switch it off. Classic DNS keeps its table, behind a disclosure that says plainly what those records do today and what they are waiting for. Nobody loses a record they published; they just stop being the first thing a name owner is asked to think about. The table gains a Host column, so records can belong to a subdomain rather than only the apex. Both halves are one signed manifest and one publish button — they always were one file, and two buttons for it only ever invited a half-saved state. Labels and values are separated in the generated rows so the words translate and the owner's own folder and URL do not. The signer accepted v1 manifests only, which would have rejected every publish from this screen. --- i18n/de.json | 40 +++++++++- i18n/el.json | 40 +++++++++- i18n/es.json | 40 +++++++++- i18n/fr.json | 40 +++++++++- i18n/pt.json | 40 +++++++++- i18n/ru.json | 40 +++++++++- index.html | 2 +- js/dashboard.js | 185 ++++++++++++++++++++++++++++++++++++++------ js/i18n.js | 2 +- js/market.js | 2 +- js/register-flow.js | 2 +- js/studio.js | 2 +- portal.html | 90 +++++++++++++++------ studio.html | 2 +- tld.html | 2 +- 15 files changed, 468 insertions(+), 61 deletions(-) diff --git a/i18n/de.json b/i18n/de.json index 550284f..199fbc0 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -774,5 +774,43 @@ "Mainnet migration": "Mainnet-Migration", "Chipnet today, mainnet later. The USD-pegged listing covenant, the TLD registry beacon, and every record type moves to mainnet with fee tiers by label length. The shape stays the same; only the network changes.": "Chipnet heute, Mainnet später. Der USD-gepeggte Listing-Covenant, das TLD-Register-Beacon und jeder Record-Typ ziehen auf das Mainnet um, mit Gebührenstufen nach Label-Länge. Die Form bleibt dieselbe; nur das Netzwerk ändert sich.", "Silent Mode's own code lives at": "Silent Modes eigener Code lebt bei", - "— a Forgejo forge with wallet-address logins. The Sia mirror for full offline reproducibility of every release is the next track.": "— eine Forgejo-Forge mit Wallet-Adress-Logins. Der Sia-Mirror für vollständige Offline-Reproduzierbarkeit jeder Release ist der nächste Schritt." + "— a Forgejo forge with wallet-address logins. The Sia mirror for full offline reproducibility of every release is the next track.": "— eine Forgejo-Forge mit Wallet-Adress-Logins. Der Sia-Mirror für vollständige Offline-Reproduzierbarkeit jeder Release ist der nächste Schritt.", + "Subdomains & paths": "Subdomains & Pfade", + "Routing": "Routing", + "Open routing →": "Routing öffnen →", + "What answers today": "Was heute antwortet", + "Rules": "Regeln", + "No rules yet — every subdomain follows its folder name.": "Noch keine Regeln — jede Subdomain folgt ihrem Ordnernamen.", + "Subdomain": "Subdomain", + "Does what": "Tut was", + "Serves a folder": "Liefert einen Ordner", + "Redirects to a URL": "Leitet auf eine URL um", + "Does not answer": "Antwortet nicht", + "Folder": "Ordner", + "Goes to": "Geht zu", + "Host": "Host", + "No DNS records.": "Keine DNS-Einträge.", + "Advanced — classic DNS interop": "Erweitert — klassische DNS-Kompatibilität", + "the front page": "die Startseite", + "the front page — no folders yet, so no subdomains": "die Startseite — noch keine Ordner, also keine Subdomains", + "the folder": "der Ordner", + "overridden by a rule below": "von einer Regel unten überschrieben", + "does not answer": "antwortet nicht", + "redirects to": "leitet um auf", + "serves the folder": "liefert den Ordner", + "the name's own front page": "die eigene Startseite des Namens", + "Every folder in this name's storage already answers on the matching subdomain — no setup, no transaction. A rule here overrides that: point a subdomain at a different folder, send it to another site, or stop it answering. Rules are signed by your key and stored beside your files; editing is free.": "Jeder Ordner im Speicher dieses Namens antwortet bereits auf der passenden Subdomain — ohne Einrichtung, ohne Transaktion. Eine Regel hier überschreibt das: richte eine Subdomain auf einen anderen Ordner, schicke sie auf eine andere Seite oder lass sie gar nicht antworten. Regeln werden mit deinem Schlüssel signiert und neben deinen Dateien gespeichert; das Bearbeiten ist kostenlos.", + "Records for clients that reach this name through ordinary DNS rather than a BCNR resolver. They are signed and stored today, but the only type anything currently reads is the address record, used as a last resort when a name carries no content record. The rest wait on the public DNS bridge — publish them if you are preparing for it, otherwise you can ignore this section.": "Einträge für Clients, die diesen Namen über gewöhnliches DNS statt über einen BCNR-Resolver erreichen. Sie werden heute signiert und gespeichert, aber gelesen wird derzeit nur der Adresseintrag, und zwar als letzte Möglichkeit, wenn ein Name keinen Inhalts-Eintrag trägt. Der Rest wartet auf die öffentliche DNS-Brücke — veröffentliche sie, wenn du dich darauf vorbereitest, sonst kannst du diesen Abschnitt ignorieren.", + "BCNR names need no nameservers — resolvers read the chain and this manifest directly. Add NS records only if you also delegate this name in classic DNS.": "BCNR-Namen brauchen keine Nameserver — Resolver lesen die Kette und dieses Manifest direkt. Füge NS-Einträge nur hinzu, wenn du diesen Namen auch im klassischen DNS delegierst.", + "Leave the folder blank to serve the name's own front page — that is how www is made to mirror it.": "Lass den Ordner leer, um die eigene Startseite des Namens zu liefern — so spiegelt www sie.", + "Visitors are sent on with a 302; the address bar changes to the target.": "Besucher werden mit einem 302 weitergeschickt; die Adresszeile wechselt zum Ziel.", + "The subdomain returns Not Found even if a folder of that name exists.": "Die Subdomain liefert Nicht gefunden, auch wenn ein Ordner dieses Namens existiert.", + "IPv4 address": "IPv4-Adresse", + "IPv6 address": "IPv6-Adresse", + "Target host (one CNAME per name)": "Zielhost (ein CNAME pro Name)", + "Mail host": "Mailhost", + "Text (SPF, DKIM, verification…)": "Text (SPF, DKIM, Verifizierung…)", + "Nameserver host": "Nameserver-Host", + "Target host": "Zielhost", + "CA domain or report URL": "CA-Domain oder Report-URL" } diff --git a/i18n/el.json b/i18n/el.json index fb9c574..a5c7bfe 100644 --- a/i18n/el.json +++ b/i18n/el.json @@ -774,5 +774,43 @@ "Mainnet migration": "Μετάβαση σε mainnet", "Chipnet today, mainnet later. The USD-pegged listing covenant, the TLD registry beacon, and every record type moves to mainnet with fee tiers by label length. The shape stays the same; only the network changes.": "Chipnet σήμερα, mainnet αργότερα. Το USD-pegged listing covenant, το beacon του μητρώου TLD και κάθε τύπος εγγραφής μετακομίζουν στο mainnet με βαθμίδες χρέωσης ανά μήκος ετικέτας. Η μορφή παραμένει η ίδια· αλλάζει μόνο το δίκτυο.", "Silent Mode's own code lives at": "Ο κώδικας του ίδιου του Silent Mode ζει στο", - "— a Forgejo forge with wallet-address logins. The Sia mirror for full offline reproducibility of every release is the next track.": "— ένα Forgejo forge με login μέσω διεύθυνσης πορτοφολιού. Το Sia mirror για πλήρη offline αναπαραγωγιμότητα κάθε έκδοσης είναι η επόμενη κίνηση." + "— a Forgejo forge with wallet-address logins. The Sia mirror for full offline reproducibility of every release is the next track.": "— ένα Forgejo forge με login μέσω διεύθυνσης πορτοφολιού. Το Sia mirror για πλήρη offline αναπαραγωγιμότητα κάθε έκδοσης είναι η επόμενη κίνηση.", + "Subdomains & paths": "Υποτομείς & διαδρομές", + "Routing": "Δρομολόγηση", + "Open routing →": "Άνοιγμα δρομολόγησης →", + "What answers today": "Τι απαντά σήμερα", + "Rules": "Κανόνες", + "No rules yet — every subdomain follows its folder name.": "Κανένας κανόνας ακόμη — κάθε υποτομέας ακολουθεί το όνομα του φακέλου του.", + "Subdomain": "Υποτομέας", + "Does what": "Κάνει τι", + "Serves a folder": "Σερβίρει έναν φάκελο", + "Redirects to a URL": "Ανακατευθύνει σε URL", + "Does not answer": "Δεν απαντά", + "Folder": "Φάκελος", + "Goes to": "Πηγαίνει σε", + "Host": "Host", + "No DNS records.": "Καμία εγγραφή DNS.", + "Advanced — classic DNS interop": "Για προχωρημένους — συμβατότητα με κλασικό DNS", + "the front page": "η αρχική σελίδα", + "the front page — no folders yet, so no subdomains": "η αρχική σελίδα — κανένας φάκελος ακόμη, άρα κανένας υποτομέας", + "the folder": "ο φάκελος", + "overridden by a rule below": "παρακάμπτεται από κανόνα παρακάτω", + "does not answer": "δεν απαντά", + "redirects to": "ανακατευθύνει σε", + "serves the folder": "σερβίρει τον φάκελο", + "the name's own front page": "η ίδια η αρχική σελίδα του ονόματος", + "Every folder in this name's storage already answers on the matching subdomain — no setup, no transaction. A rule here overrides that: point a subdomain at a different folder, send it to another site, or stop it answering. Rules are signed by your key and stored beside your files; editing is free.": "Κάθε φάκελος στον αποθηκευτικό χώρο αυτού του ονόματος απαντά ήδη στον αντίστοιχο υποτομέα — χωρίς ρύθμιση, χωρίς συναλλαγή. Ένας κανόνας εδώ το παρακάμπτει: στρέψε έναν υποτομέα σε άλλον φάκελο, στείλ' τον σε άλλον ιστότοπο ή σταμάτα τον να απαντά. Οι κανόνες υπογράφονται με το κλειδί σου και αποθηκεύονται δίπλα στα αρχεία σου· η επεξεργασία είναι δωρεάν.", + "Records for clients that reach this name through ordinary DNS rather than a BCNR resolver. They are signed and stored today, but the only type anything currently reads is the address record, used as a last resort when a name carries no content record. The rest wait on the public DNS bridge — publish them if you are preparing for it, otherwise you can ignore this section.": "Εγγραφές για προγράμματα που φτάνουν σε αυτό το όνομα μέσω συνηθισμένου DNS αντί για BCNR resolver. Υπογράφονται και αποθηκεύονται ήδη, αλλά ο μόνος τύπος που διαβάζεται σήμερα είναι η εγγραφή διεύθυνσης, ως έσχατη λύση όταν ένα όνομα δεν έχει εγγραφή περιεχομένου. Τα υπόλοιπα περιμένουν τη δημόσια γέφυρα DNS — δημοσίευσέ τα αν προετοιμάζεσαι, αλλιώς αγνόησε αυτή την ενότητα.", + "BCNR names need no nameservers — resolvers read the chain and this manifest directly. Add NS records only if you also delegate this name in classic DNS.": "Τα ονόματα BCNR δεν χρειάζονται nameservers — οι resolvers διαβάζουν την αλυσίδα και αυτό το μανιφέστο απευθείας. Πρόσθεσε εγγραφές NS μόνο αν εκχωρείς αυτό το όνομα και στο κλασικό DNS.", + "Leave the folder blank to serve the name's own front page — that is how www is made to mirror it.": "Άσε τον φάκελο κενό για να σερβίρεται η ίδια η αρχική σελίδα του ονόματος — έτσι την καθρεφτίζει το www.", + "Visitors are sent on with a 302; the address bar changes to the target.": "Οι επισκέπτες προωθούνται με 302· η γραμμή διευθύνσεων αλλάζει στον προορισμό.", + "The subdomain returns Not Found even if a folder of that name exists.": "Ο υποτομέας επιστρέφει Δεν βρέθηκε, ακόμη κι αν υπάρχει φάκελος με αυτό το όνομα.", + "IPv4 address": "Διεύθυνση IPv4", + "IPv6 address": "Διεύθυνση IPv6", + "Target host (one CNAME per name)": "Host προορισμού (ένα CNAME ανά όνομα)", + "Mail host": "Host αλληλογραφίας", + "Text (SPF, DKIM, verification…)": "Κείμενο (SPF, DKIM, επαλήθευση…)", + "Nameserver host": "Host nameserver", + "Target host": "Host προορισμού", + "CA domain or report URL": "Τομέας CA ή URL αναφοράς" } diff --git a/i18n/es.json b/i18n/es.json index 1caf91e..a44092c 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -774,5 +774,43 @@ "Mainnet migration": "Migración a mainnet", "Chipnet today, mainnet later. The USD-pegged listing covenant, the TLD registry beacon, and every record type moves to mainnet with fee tiers by label length. The shape stays the same; only the network changes.": "Chipnet hoy, mainnet después. El contrato de listado en dólares, el beacon del registro de TLD, y cada tipo de registro se mudan a mainnet con niveles de tarifa por longitud de etiqueta. La forma queda igual; solo cambia la red.", "Silent Mode's own code lives at": "El código propio de Silent Mode vive en", - "— a Forgejo forge with wallet-address logins. The Sia mirror for full offline reproducibility of every release is the next track.": "— un forge Forgejo con inicios de sesión por dirección de cartera. El mirror en Sia para reproducibilidad offline completa de cada release es la siguiente tarea." + "— a Forgejo forge with wallet-address logins. The Sia mirror for full offline reproducibility of every release is the next track.": "— un forge Forgejo con inicios de sesión por dirección de cartera. El mirror en Sia para reproducibilidad offline completa de cada release es la siguiente tarea.", + "Subdomains & paths": "Subdominios y rutas", + "Routing": "Enrutado", + "Open routing →": "Abrir enrutado →", + "What answers today": "Qué responde hoy", + "Rules": "Reglas", + "No rules yet — every subdomain follows its folder name.": "Aún no hay reglas: cada subdominio sigue el nombre de su carpeta.", + "Subdomain": "Subdominio", + "Does what": "Qué hace", + "Serves a folder": "Sirve una carpeta", + "Redirects to a URL": "Redirige a una URL", + "Does not answer": "No responde", + "Folder": "Carpeta", + "Goes to": "Va a", + "Host": "Host", + "No DNS records.": "Sin registros DNS.", + "Advanced — classic DNS interop": "Avanzado: compatibilidad con DNS clásico", + "the front page": "la portada", + "the front page — no folders yet, so no subdomains": "la portada: aún no hay carpetas, así que no hay subdominios", + "the folder": "la carpeta", + "overridden by a rule below": "anulado por una regla de abajo", + "does not answer": "no responde", + "redirects to": "redirige a", + "serves the folder": "sirve la carpeta", + "the name's own front page": "la portada del propio nombre", + "Every folder in this name's storage already answers on the matching subdomain — no setup, no transaction. A rule here overrides that: point a subdomain at a different folder, send it to another site, or stop it answering. Rules are signed by your key and stored beside your files; editing is free.": "Cada carpeta del almacenamiento de este nombre ya responde en el subdominio correspondiente, sin configurar nada y sin ninguna transacción. Una regla aquí lo anula: apunta un subdominio a otra carpeta, envíalo a otro sitio o haz que deje de responder. Las reglas se firman con tu clave y se guardan junto a tus archivos; editarlas es gratis.", + "Records for clients that reach this name through ordinary DNS rather than a BCNR resolver. They are signed and stored today, but the only type anything currently reads is the address record, used as a last resort when a name carries no content record. The rest wait on the public DNS bridge — publish them if you are preparing for it, otherwise you can ignore this section.": "Registros para clientes que llegan a este nombre por DNS corriente en lugar de por un resolutor BCNR. Ya se firman y se guardan, pero lo único que algo lee hoy es el registro de dirección, como último recurso cuando un nombre no lleva registro de contenido. El resto espera al puente DNS público: publícalos si te estás preparando para él; si no, puedes ignorar esta sección.", + "BCNR names need no nameservers — resolvers read the chain and this manifest directly. Add NS records only if you also delegate this name in classic DNS.": "Los nombres BCNR no necesitan servidores de nombres: los resolutores leen la cadena y este manifiesto directamente. Añade registros NS solo si además delegas este nombre en el DNS clásico.", + "Leave the folder blank to serve the name's own front page — that is how www is made to mirror it.": "Deja la carpeta en blanco para servir la portada del propio nombre: así es como www la refleja.", + "Visitors are sent on with a 302; the address bar changes to the target.": "A los visitantes se les envía con un 302; la barra de direcciones cambia al destino.", + "The subdomain returns Not Found even if a folder of that name exists.": "El subdominio devuelve No encontrado aunque exista una carpeta con ese nombre.", + "IPv4 address": "Dirección IPv4", + "IPv6 address": "Dirección IPv6", + "Target host (one CNAME per name)": "Host de destino (un CNAME por nombre)", + "Mail host": "Host de correo", + "Text (SPF, DKIM, verification…)": "Texto (SPF, DKIM, verificación…)", + "Nameserver host": "Host del servidor de nombres", + "Target host": "Host de destino", + "CA domain or report URL": "Dominio de la CA o URL de informe" } diff --git a/i18n/fr.json b/i18n/fr.json index 455caaa..2dd8d12 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -774,5 +774,43 @@ "Mainnet migration": "Migration vers le mainnet", "Chipnet today, mainnet later. The USD-pegged listing covenant, the TLD registry beacon, and every record type moves to mainnet with fee tiers by label length. The shape stays the same; only the network changes.": "Chipnet aujourd'hui, mainnet plus tard. Le contrat de listing indexé sur le dollar, le beacon du registre des TLD, et tous les types d'enregistrement passent en mainnet avec des paliers de frais selon la longueur du libellé. La forme reste la même ; seul le réseau change.", "Silent Mode's own code lives at": "Le code propre à Silent Mode vit sur", - "— a Forgejo forge with wallet-address logins. The Sia mirror for full offline reproducibility of every release is the next track.": "— une forge Forgejo avec des connexions par adresse de portefeuille. Le miroir Sia pour une reproductibilité hors ligne complète de chaque release est la prochaine étape." + "— a Forgejo forge with wallet-address logins. The Sia mirror for full offline reproducibility of every release is the next track.": "— une forge Forgejo avec des connexions par adresse de portefeuille. Le miroir Sia pour une reproductibilité hors ligne complète de chaque release est la prochaine étape.", + "Subdomains & paths": "Sous-domaines et chemins", + "Routing": "Routage", + "Open routing →": "Ouvrir le routage →", + "What answers today": "Ce qui répond aujourd'hui", + "Rules": "Règles", + "No rules yet — every subdomain follows its folder name.": "Aucune règle pour l'instant — chaque sous-domaine suit le nom de son dossier.", + "Subdomain": "Sous-domaine", + "Does what": "Fait quoi", + "Serves a folder": "Sert un dossier", + "Redirects to a URL": "Redirige vers une URL", + "Does not answer": "Ne répond pas", + "Folder": "Dossier", + "Goes to": "Va vers", + "Host": "Hôte", + "No DNS records.": "Aucun enregistrement DNS.", + "Advanced — classic DNS interop": "Avancé — compatibilité DNS classique", + "the front page": "la page d'accueil", + "the front page — no folders yet, so no subdomains": "la page d'accueil — aucun dossier pour l'instant, donc aucun sous-domaine", + "the folder": "le dossier", + "overridden by a rule below": "remplacé par une règle ci-dessous", + "does not answer": "ne répond pas", + "redirects to": "redirige vers", + "serves the folder": "sert le dossier", + "the name's own front page": "la page d'accueil du nom lui-même", + "Every folder in this name's storage already answers on the matching subdomain — no setup, no transaction. A rule here overrides that: point a subdomain at a different folder, send it to another site, or stop it answering. Rules are signed by your key and stored beside your files; editing is free.": "Chaque dossier du stockage de ce nom répond déjà sur le sous-domaine correspondant — sans configuration, sans transaction. Une règle ici remplace cela : pointez un sous-domaine vers un autre dossier, envoyez-le vers un autre site, ou faites-le cesser de répondre. Les règles sont signées avec votre clé et rangées à côté de vos fichiers ; les modifier ne coûte rien.", + "Records for clients that reach this name through ordinary DNS rather than a BCNR resolver. They are signed and stored today, but the only type anything currently reads is the address record, used as a last resort when a name carries no content record. The rest wait on the public DNS bridge — publish them if you are preparing for it, otherwise you can ignore this section.": "Enregistrements pour les clients qui atteignent ce nom par le DNS ordinaire plutôt que par un résolveur BCNR. Ils sont déjà signés et stockés, mais le seul type que quoi que ce soit lit aujourd'hui est l'enregistrement d'adresse, en dernier recours lorsqu'un nom ne porte aucun enregistrement de contenu. Le reste attend la passerelle DNS publique — publiez-les si vous vous y préparez, sinon vous pouvez ignorer cette section.", + "BCNR names need no nameservers — resolvers read the chain and this manifest directly. Add NS records only if you also delegate this name in classic DNS.": "Les noms BCNR n'ont pas besoin de serveurs de noms — les résolveurs lisent la chaîne et ce manifeste directement. N'ajoutez des enregistrements NS que si vous déléguez aussi ce nom dans le DNS classique.", + "Leave the folder blank to serve the name's own front page — that is how www is made to mirror it.": "Laissez le dossier vide pour servir la page d'accueil du nom lui-même — c'est ainsi que www la reflète.", + "Visitors are sent on with a 302; the address bar changes to the target.": "Les visiteurs sont renvoyés par un 302 ; la barre d'adresse change pour la cible.", + "The subdomain returns Not Found even if a folder of that name exists.": "Le sous-domaine renvoie Introuvable même s'il existe un dossier de ce nom.", + "IPv4 address": "Adresse IPv4", + "IPv6 address": "Adresse IPv6", + "Target host (one CNAME per name)": "Hôte cible (un seul CNAME par nom)", + "Mail host": "Hôte de messagerie", + "Text (SPF, DKIM, verification…)": "Texte (SPF, DKIM, vérification…)", + "Nameserver host": "Hôte du serveur de noms", + "Target host": "Hôte cible", + "CA domain or report URL": "Domaine de l'AC ou URL de rapport" } diff --git a/i18n/pt.json b/i18n/pt.json index 5be60f9..b70e0ba 100644 --- a/i18n/pt.json +++ b/i18n/pt.json @@ -774,5 +774,43 @@ "Mainnet migration": "Migração para o mainnet", "Chipnet today, mainnet later. The USD-pegged listing covenant, the TLD registry beacon, and every record type moves to mainnet with fee tiers by label length. The shape stays the same; only the network changes.": "Chipnet hoje, mainnet depois. O contrato de listagem em dólares, o beacon do registro de TLD e cada tipo de registro migram para o mainnet com faixas de taxa por comprimento de rótulo. A forma continua a mesma; só a rede muda.", "Silent Mode's own code lives at": "O código do próprio Silent Mode vive em", - "— a Forgejo forge with wallet-address logins. The Sia mirror for full offline reproducibility of every release is the next track.": "— um forge Forgejo com logins por endereço de carteira. O mirror em Sia para reprodutibilidade offline completa de cada release é a próxima etapa." + "— a Forgejo forge with wallet-address logins. The Sia mirror for full offline reproducibility of every release is the next track.": "— um forge Forgejo com logins por endereço de carteira. O mirror em Sia para reprodutibilidade offline completa de cada release é a próxima etapa.", + "Subdomains & paths": "Subdomínios e caminhos", + "Routing": "Encaminhamento", + "Open routing →": "Abrir encaminhamento →", + "What answers today": "O que responde hoje", + "Rules": "Regras", + "No rules yet — every subdomain follows its folder name.": "Ainda sem regras — cada subdomínio segue o nome da sua pasta.", + "Subdomain": "Subdomínio", + "Does what": "Faz o quê", + "Serves a folder": "Serve uma pasta", + "Redirects to a URL": "Redireciona para um URL", + "Does not answer": "Não responde", + "Folder": "Pasta", + "Goes to": "Vai para", + "Host": "Host", + "No DNS records.": "Sem registos DNS.", + "Advanced — classic DNS interop": "Avançado — compatibilidade com DNS clássico", + "the front page": "a página inicial", + "the front page — no folders yet, so no subdomains": "a página inicial — ainda sem pastas, portanto sem subdomínios", + "the folder": "a pasta", + "overridden by a rule below": "substituído por uma regra abaixo", + "does not answer": "não responde", + "redirects to": "redireciona para", + "serves the folder": "serve a pasta", + "the name's own front page": "a página inicial do próprio nome", + "Every folder in this name's storage already answers on the matching subdomain — no setup, no transaction. A rule here overrides that: point a subdomain at a different folder, send it to another site, or stop it answering. Rules are signed by your key and stored beside your files; editing is free.": "Cada pasta no armazenamento deste nome já responde no subdomínio correspondente — sem configuração, sem transação. Uma regra aqui substitui isso: aponte um subdomínio para outra pasta, envie-o para outro site, ou faça-o deixar de responder. As regras são assinadas com a sua chave e guardadas ao lado dos seus ficheiros; editar não custa nada.", + "Records for clients that reach this name through ordinary DNS rather than a BCNR resolver. They are signed and stored today, but the only type anything currently reads is the address record, used as a last resort when a name carries no content record. The rest wait on the public DNS bridge — publish them if you are preparing for it, otherwise you can ignore this section.": "Registos para clientes que chegam a este nome por DNS comum em vez de um resolvedor BCNR. Já são assinados e guardados, mas o único tipo que alguma coisa lê hoje é o registo de endereço, como último recurso quando um nome não tem registo de conteúdo. Os restantes esperam pela ponte DNS pública — publique-os se estiver a preparar-se para ela, caso contrário pode ignorar esta secção.", + "BCNR names need no nameservers — resolvers read the chain and this manifest directly. Add NS records only if you also delegate this name in classic DNS.": "Os nomes BCNR não precisam de servidores de nomes — os resolvedores leem a cadeia e este manifesto diretamente. Adicione registos NS apenas se também delegar este nome no DNS clássico.", + "Leave the folder blank to serve the name's own front page — that is how www is made to mirror it.": "Deixe a pasta em branco para servir a página inicial do próprio nome — é assim que o www a espelha.", + "Visitors are sent on with a 302; the address bar changes to the target.": "Os visitantes são encaminhados com um 302; a barra de endereço muda para o destino.", + "The subdomain returns Not Found even if a folder of that name exists.": "O subdomínio devolve Não encontrado mesmo que exista uma pasta com esse nome.", + "IPv4 address": "Endereço IPv4", + "IPv6 address": "Endereço IPv6", + "Target host (one CNAME per name)": "Host de destino (um CNAME por nome)", + "Mail host": "Host de correio", + "Text (SPF, DKIM, verification…)": "Texto (SPF, DKIM, verificação…)", + "Nameserver host": "Host do servidor de nomes", + "Target host": "Host de destino", + "CA domain or report URL": "Domínio da AC ou URL de relatório" } diff --git a/i18n/ru.json b/i18n/ru.json index 5aaa3d4..03c2387 100644 --- a/i18n/ru.json +++ b/i18n/ru.json @@ -774,5 +774,43 @@ "Mainnet migration": "Переход на mainnet", "Chipnet today, mainnet later. The USD-pegged listing covenant, the TLD registry beacon, and every record type moves to mainnet with fee tiers by label length. The shape stays the same; only the network changes.": "Сегодня chipnet, потом mainnet. Ковенант листинга в долларах, маяк реестра TLD и каждый тип записи переезжают в mainnet с уровнями комиссии по длине метки. Форма остаётся прежней — меняется только сеть.", "Silent Mode's own code lives at": "Собственный код Silent Mode живёт по адресу", - "— a Forgejo forge with wallet-address logins. The Sia mirror for full offline reproducibility of every release is the next track.": "— это Forgejo-форджа со входом по адресу кошелька. Sia-зеркало для полной офлайн-воспроизводимости каждого релиза — следующая задача." + "— a Forgejo forge with wallet-address logins. The Sia mirror for full offline reproducibility of every release is the next track.": "— это Forgejo-форджа со входом по адресу кошелька. Sia-зеркало для полной офлайн-воспроизводимости каждого релиза — следующая задача.", + "Subdomains & paths": "Поддомены и пути", + "Routing": "Маршрутизация", + "Open routing →": "Открыть маршрутизацию →", + "What answers today": "Что отвечает сейчас", + "Rules": "Правила", + "No rules yet — every subdomain follows its folder name.": "Правил пока нет — каждый поддомен следует имени своей папки.", + "Subdomain": "Поддомен", + "Does what": "Что делает", + "Serves a folder": "Отдаёт папку", + "Redirects to a URL": "Перенаправляет на URL", + "Does not answer": "Не отвечает", + "Folder": "Папка", + "Goes to": "Ведёт на", + "Host": "Хост", + "No DNS records.": "Записей DNS нет.", + "Advanced — classic DNS interop": "Дополнительно — совместимость с обычным DNS", + "the front page": "главная страница", + "the front page — no folders yet, so no subdomains": "главная страница — папок пока нет, значит и поддоменов нет", + "the folder": "папка", + "overridden by a rule below": "переопределено правилом ниже", + "does not answer": "не отвечает", + "redirects to": "перенаправляет на", + "serves the folder": "отдаёт папку", + "the name's own front page": "собственная главная страница имени", + "Every folder in this name's storage already answers on the matching subdomain — no setup, no transaction. A rule here overrides that: point a subdomain at a different folder, send it to another site, or stop it answering. Rules are signed by your key and stored beside your files; editing is free.": "Каждая папка в хранилище этого имени уже отвечает на соответствующем поддомене — без настройки и без транзакции. Правило здесь это переопределяет: направьте поддомен на другую папку, отправьте его на другой сайт или запретите отвечать. Правила подписаны вашим ключом и лежат рядом с вашими файлами; редактирование бесплатно.", + "Records for clients that reach this name through ordinary DNS rather than a BCNR resolver. They are signed and stored today, but the only type anything currently reads is the address record, used as a last resort when a name carries no content record. The rest wait on the public DNS bridge — publish them if you are preparing for it, otherwise you can ignore this section.": "Записи для клиентов, которые приходят к этому имени через обычный DNS, а не через резолвер BCNR. Они уже подписываются и хранятся, но читается сегодня только адресная запись — как последний вариант, когда у имени нет записи содержимого. Остальные ждут публичного моста DNS: публикуйте их, если готовитесь к нему, иначе этот раздел можно не трогать.", + "BCNR names need no nameservers — resolvers read the chain and this manifest directly. Add NS records only if you also delegate this name in classic DNS.": "Именам BCNR не нужны nameserver'ы — резолверы читают цепочку и этот манифест напрямую. Добавляйте записи NS только если делегируете это имя и в обычном DNS.", + "Leave the folder blank to serve the name's own front page — that is how www is made to mirror it.": "Оставьте папку пустой, чтобы отдавалась собственная главная страница имени — так www её зеркалит.", + "Visitors are sent on with a 302; the address bar changes to the target.": "Посетителей отправляет 302; адресная строка меняется на цель.", + "The subdomain returns Not Found even if a folder of that name exists.": "Поддомен вернёт «Не найдено», даже если папка с таким именем существует.", + "IPv4 address": "Адрес IPv4", + "IPv6 address": "Адрес IPv6", + "Target host (one CNAME per name)": "Целевой хост (один CNAME на имя)", + "Mail host": "Почтовый хост", + "Text (SPF, DKIM, verification…)": "Текст (SPF, DKIM, подтверждение…)", + "Nameserver host": "Хост nameserver", + "Target host": "Целевой хост", + "CA domain or report URL": "Домен УЦ или URL отчёта" } diff --git a/index.html b/index.html index 0cad918..073f5f6 100644 --- a/index.html +++ b/index.html @@ -176,7 +176,7 @@ .verify dd{margin:2px 0 0;font-family:ui-monospace,monospace;font-size:12.5px;word-break:break-all;color:var(--ink)} footer{border-top:1px solid var(--line);padding:2.4rem 0 3.5rem;color:var(--dim);font-size:13px;text-align:center;margin-top:2.5rem} - + diff --git a/js/dashboard.js b/js/dashboard.js index 345e6ca..25f5118 100644 --- a/js/dashboard.js +++ b/js/dashboard.js @@ -10,7 +10,7 @@ // is the persistent device session (js/session.js): sign in once, come back // without a prompt until you sign out. -import * as BNS from "https://silentmode.st/js/bns-register.js?v=20260927net"; +import * as BNS from "https://silentmode.st/js/bns-register.js?v=20260928hosts"; const API = "https://silentmode.st"; const TLD_API = "https://navigate.st/api/tlds?include_hidden=1"; @@ -31,7 +31,7 @@ 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 current = { name: null, entry: null, manifestSeq: 0, rows: [], hostRules: [], dirty: false }; let currentTld = null; // ---------- profile breadcrumb (shared with profile-menu.js) ---------- @@ -341,7 +341,7 @@ function domainRow(e, { compact = false } = {}) { const dnsBadge = `${dn ? `DNS · ${dn}` : (d?.status === "nopointer" ? "DNS needs s3" : (d ? "no DNS" : "DNS …"))}`; const acts = compact ? "" : `
Open → - +
`; return `
${nameHtml(e)} ${esc(pt.label)} ${dnsBadge}
@@ -397,7 +397,7 @@ async function openDomain(name) { $("dd-kv").innerHTML = `
Status
This wallet does not hold ${esc(name)}.
`; return; } - current = { name, entry, manifestSeq: 0, rows: [], dirty: false }; + current = { name, entry, manifestSeq: 0, rows: [], hostRules: [], dirty: false }; $("dd-name").textContent = name; $("dd-crumb").textContent = name; $("dd-view").href = siteUrl(name); fillSummary(entry); @@ -447,7 +447,7 @@ function fillDnsBadge() { const sum = $("dd-dns-summary"); if (!c) { sum.textContent = "Loading…"; return; } if (c.status === "nopointer") { sum.innerHTML = `This name has no s3 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. Add some →`; sum.querySelector("[data-sub-go]").onclick = (ev) => { ev.preventDefault(); setSub("dns"); }; return; } + if (!n) { sum.innerHTML = `No DNS records. Open routing →`; sum.querySelector("[data-sub-go]").onclick = (ev) => { ev.preventDefault(); setSub("routing"); }; 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(`${k} ${d[k].map((v) => esc(String(v).slice(0, 48))).join(", ")}`); @@ -464,16 +464,52 @@ const DNS_HELP = { 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) { +// A row's `host` is the subdomain it belongs to; "" means the name itself. +function rowsFromDns(d, host = "") { 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 }); + for (const t of ["A", "AAAA", "TXT", "NS"]) for (const v of (Array.isArray(d?.[t]) ? d[t] : [])) rows.push({ host, type: t, value: String(v) }); + if (d?.CNAME) rows.push({ host, type: "CNAME", value: String(d.CNAME) }); + for (const m of (Array.isArray(d?.MX) ? d.MX : [])) rows.push({ host, type: "MX", value: String(m.host), prio: Number(m.pref) || 0 }); + for (const s of (Array.isArray(d?.SRV) ? d.SRV : [])) rows.push({ host, 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({ host, type: "CAA", value: String(c.value), tag: c.tag || "issue", flags: Number(c.flags) || 0 }); return rows; } -function manifestFromRows(rows) { +// Apex records plus every per-host block, flattened into one editable table. +function rowsFromManifest(m) { + const rows = rowsFromDns(m?.dns, ""); + const hosts = m?.hosts && typeof m.hosts === "object" ? m.hosts : {}; + for (const key of Object.keys(hosts)) rows.push(...rowsFromDns(hosts[key]?.dns, key)); + return rows; +} +// The routing half of `hosts`: what each subdomain serves. +function hostRulesFromManifest(m) { + const hosts = m?.hosts && typeof m.hosts === "object" ? m.hosts : {}; + const out = []; + for (const key of Object.keys(hosts).sort()) { + const h = hosts[key] || {}; + if (h.block === true) out.push({ host: key, mode: "block" }); + else if (typeof h.u === "string" && h.u) out.push({ host: key, mode: "u", u: h.u }); + else if (typeof h.path === "string") out.push({ host: key, mode: "path", path: h.path }); + // an entry with only `dns` is not a routing rule — it shows in the table below + } + return out; +} +// Compose the `hosts` block from both halves. A subdomain can carry a routing +// rule, DNS records, or both, and they live in the same entry. +function hostsFromState(rules, rows) { + const out = {}; + for (const r of rules) { + if (r.mode === "block") out[r.host] = { block: true }; + else if (r.mode === "u") out[r.host] = { u: r.u }; + else out[r.host] = { path: r.path || "" }; + } + for (const key of new Set(rows.filter((r) => r.host).map((r) => r.host))) { + const dns = dnsFromRows(rows.filter((r) => r.host === key)); + out[key] = { ...(out[key] || {}), dns }; + } + return out; +} +function dnsFromRows(rows) { const d = { A: [], AAAA: [], MX: [], TXT: [], CNAME: null, NS: [] }; const srv = [], caa = []; for (const r of rows) { @@ -496,19 +532,112 @@ function extraOf(r) { function renderDnsTable() { const tb = $("dns-table").querySelector("tbody"); tb.innerHTML = current.rows.map((r, i) => ` + ${r.host ? esc(r.host) : "@"} ${esc(r.type)}${esc(r.value)}${esc(extraOf(r))} `).join(""); $("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(); }); + tb.querySelectorAll("[data-del]").forEach((b) => b.onclick = () => { current.rows.splice(Number(b.dataset.del), 1); markRoutingDirty(); }); + renderSeq(); } +function renderSeq() { + $("rt-seq").textContent = `seq ${current.manifestSeq}${current.dirty ? " · unsaved" : ""}`; +} +function markRoutingDirty() { current.dirty = true; renderHostTable(); renderDnsTable(); } + +// ---------- routing rules ---------- +const HOST_KEY_RE = /^(\*|[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*)$/; +// Split so the words are translatable and the owner's own folder or URL is +// not: i18n keys on a node's exact English text, and an interpolated value +// would make every row its own unmatchable key. +function hostServes(r) { + if (r.mode === "block") return { label: "does not answer" }; + if (r.mode === "u") return { label: "redirects to", value: r.u }; + const p = (r.path || "").replace(/^\/+|\/+$/g, ""); + return p ? { label: "serves the folder", value: `/${p}/` } : { label: "the name's own front page" }; +} +const servesHtml = (r) => { const s = hostServes(r); return `${esc(s.label)}${s.value ? ` ${esc(s.value)}` : ""}`; }; +function renderHostTable() { + const tb = $("host-table").querySelector("tbody"); + tb.innerHTML = current.hostRules.map((r, i) => ` + ${esc(r.host)}.${esc(current.name)} + ${servesHtml(r)} + `).join(""); + $("host-empty").classList.toggle("hidden", current.hostRules.length > 0); + tb.querySelectorAll("[data-hdel]").forEach((b) => b.onclick = () => { current.hostRules.splice(Number(b.dataset.hdel), 1); markRoutingDirty(); renderRoutingMap(); }); + renderSeq(); +} +// What answers today: the folders in the name's storage, each of which already +// serves on a matching subdomain, and which of them a rule overrides. +async function renderRoutingMap() { + const el = $("rt-map"); + const name = current.name; + if (!name) return; + let folders = []; + try { + const r = await fetch(`${API}/api/site/${encodeURIComponent(name)}?src=live`, { cache: "no-store" }); + if (r.ok) { + const j = await r.json(); + folders = [...new Set((j.files || []).map((f) => String(f.path).split("/")[0]).filter((x) => x && !x.includes(".")))].sort(); + } + } catch { /* storage unreadable — the rules below still stand on their own */ } + if (current.name !== name) return; + const ruled = new Set(current.hostRules.map((r) => r.host)); + const rows = []; + rows.push(`
${esc(name)}the front page
`); + for (const f of folders) { + const over = ruled.has(f); + rows.push(`
${esc(f)}.${esc(name)}${over ? "overridden by a rule below" : `the folder /${esc(f)}/`}
`); + } + for (const r of current.hostRules) { + if (folders.includes(r.host)) continue; + rows.push(`
${esc(r.host)}.${esc(name)}${servesHtml(r)}
`); + } + el.className = "rt-map"; + el.innerHTML = rows.length > 1 || current.hostRules.length + ? rows.join("") + : `
${esc(name)}the front page — no folders yet, so no subdomains
`; +} +$("host-mode").addEventListener("change", () => { + const m = $("host-mode").value; + document.querySelectorAll("[data-hfor]").forEach((o) => { o.hidden = o.dataset.hfor !== m; }); + $("host-help").textContent = m === "path" ? "Leave the folder blank to serve the name's own front page — that is how www is made to mirror it." + : m === "u" ? "Visitors are sent on with a 302; the address bar changes to the target." + : "The subdomain returns Not Found even if a folder of that name exists."; +}); +$("host-add").addEventListener("click", () => { + const host = $("host-key").value.trim().toLowerCase().replace(/\.$/, ""); + const mode = $("host-mode").value; + if (!host) return $("host-key").focus(); + if (!HOST_KEY_RE.test(host)) return setMsg("dns-msg", `"${host}" is not a subdomain label. Use something like shop, or a.b, or * for everything else.`, "err"); + if (current.hostRules.some((r) => r.host === host)) return setMsg("dns-msg", `There is already a rule for ${host}.`, "err"); + const rule = { host, mode }; + if (mode === "u") { + const u = $("host-u").value.trim(); + if (!/^https?:\/\/\S+$/i.test(u)) return setMsg("dns-msg", "A redirect needs an http or https URL.", "err"); + rule.u = u; + } else if (mode === "path") { + const path = $("host-path").value.trim().replace(/^\/+|\/+$/g, ""); + if (path && !/^[A-Za-z0-9._\-/]{1,200}$/.test(path)) return setMsg("dns-msg", "A folder may only contain letters, digits, dot, dash, underscore and /.", "err"); + if (path.includes("..")) return setMsg("dns-msg", "A folder cannot contain \"..\".", "err"); + rule.path = path; + } + current.hostRules.push(rule); + current.hostRules.sort((a, b) => a.host.localeCompare(b.host)); + $("host-key").value = ""; $("host-path").value = ""; $("host-u").value = ""; + setMsg("dns-msg", ""); + markRoutingDirty(); renderRoutingMap(); +}); +$("host-key").addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); $("host-add").click(); } }); 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.rows = c.status === "ok" ? rowsFromManifest(c.raw) : []; + current.hostRules = c.status === "ok" ? hostRulesFromManifest(c.raw) : []; current.dirty = false; + renderHostTable(); renderDnsTable(); + renderRoutingMap(); updateDnsBadge(name); const pub = $("dns-publish"); if (c.status === "nopointer") { @@ -518,7 +647,7 @@ async function loadDns(name, force) { } function updateDnsForm() { const t = $("dns-type").value; - document.querySelectorAll(".addrow .opt").forEach((o) => { o.hidden = !o.dataset.for.split(" ").includes(t); }); + document.querySelectorAll(".addrow .opt[data-for]").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." : @@ -526,20 +655,24 @@ function updateDnsForm() { t === "CAA" ? "Restricts which certificate authorities may issue for this name in classic DNS." : ""; } $("dns-type").addEventListener("change", updateDnsForm); updateDnsForm(); +$("host-mode").dispatchEvent(new Event("change")); $("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 }; + const host = $("dns-host").value.trim().toLowerCase().replace(/^@$/, "").replace(/\.$/, ""); + if (host && !HOST_KEY_RE.test(host)) return setMsg("dns-msg", `"${host}" is not a subdomain label. Leave it blank (or "@") for the name itself.`, "err"); + const row = { host, 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; + // One CNAME per host, not per name — that is the classic-DNS rule. + if (type === "CNAME") current.rows = current.rows.filter((r) => !(r.type === "CNAME" && r.host === host)); + current.rows.push(row); $("dns-value").value = ""; $("dns-prio").value = ""; $("dns-weight").value = ""; $("dns-port").value = ""; - setMsg("dns-msg", ""); renderDnsTable(); + setMsg("dns-msg", ""); markRoutingDirty(); }); $("dns-value").addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); $("dns-add").click(); } }); $("dns-reload").addEventListener("click", () => loadDns(current.name, true)); @@ -549,8 +682,14 @@ $("dns-publish").addEventListener("click", async () => { 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 hosts = hostsFromState(current.hostRules, current.rows); + const manifest = { + v: 2, name: current.name, seq: (current.manifestSeq || 0) + 1, updated_at: new Date().toISOString(), + dns: dnsFromRows(current.rows.filter((r) => !r.host)), + }; + if (Object.keys(hosts).length) manifest.hosts = hosts; + const nHosts = Object.keys(hosts).length; + push(`composed manifest · seq ${manifest.seq} · ${current.rows.length} record${current.rows.length === 1 ? "" : "s"}${nHosts ? ` · ${nHosts} host${nHosts === 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) }); @@ -559,7 +698,7 @@ $("dns-publish").addEventListener("click", async () => { 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(); + renderHostTable(); renderDnsTable(); renderRoutingMap(); 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 →"; } diff --git a/js/i18n.js b/js/i18n.js index 09bbc85..d6131b7 100644 --- a/js/i18n.js +++ b/js/i18n.js @@ -22,7 +22,7 @@ (function () { var STORE = "sirius-lang"; var CACHE = "sirius-i18n:"; - var VERSION = "20260921c"; // bump when dictionaries change + var VERSION = "20260928routing"; // bump when dictionaries change var LANGS = { en: "English", de: "Deutsch", diff --git a/js/market.js b/js/market.js index 24121a7..81bbab6 100644 --- a/js/market.js +++ b/js/market.js @@ -7,7 +7,7 @@ // stays valid while their certificate UTXO is unspent, so the gateway prunes // stale offers and the buyer's broadcast is the final arbiter. -import * as BNS from "https://silentmode.st/js/bns-register.js?v=20260927net"; +import * as BNS from "https://silentmode.st/js/bns-register.js?v=20260928hosts"; const API = "https://silentmode.st"; const $ = (id) => document.getElementById(id); diff --git a/js/register-flow.js b/js/register-flow.js index 32fe9f3..a275e11 100644 --- a/js/register-flow.js +++ b/js/register-flow.js @@ -7,7 +7,7 @@ // Injects its own modal HTML into and its own CSS into on load // so the host page needs nothing but a single script include. -import * as BNS from "https://silentmode.st/js/bns-register.js?v=20260927net"; +import * as BNS from "https://silentmode.st/js/bns-register.js?v=20260928hosts"; const $ = (id) => document.getElementById(id); const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&":"&","<":"<",">":">",'"':""","'":"'" }[c])); diff --git a/js/studio.js b/js/studio.js index 071ea4a..ab61b9b 100644 --- a/js/studio.js +++ b/js/studio.js @@ -11,7 +11,7 @@ // The gateway never holds a key: it verifies each upload's signature // against the current NFT owner and refuses writes outside bns//. -import * as BNS from "https://silentmode.st/js/bns-register.js?v=20260927net"; +import * as BNS from "https://silentmode.st/js/bns-register.js?v=20260928hosts"; import { initAssistant } from "./studio-ai.js?v=20260920ai"; const API = "https://silentmode.st"; diff --git a/portal.html b/portal.html index 125507d..19898d5 100644 --- a/portal.html +++ b/portal.html @@ -179,6 +179,19 @@ .drow .n .tld{color:var(--acid)} .drow .meta{grid-column:1/-1;display:flex;gap:6px;flex-wrap:wrap;align-items:center;color:var(--mut);font-size:12px} .drow .acts{display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end} + .sub-h{font-size:12px;letter-spacing:.1em;text-transform:uppercase;color:var(--dim);margin:22px 0 10px;font-weight:600} + .adv{margin-top:22px;border-top:1px solid var(--line);padding-top:14px} + .adv > summary{cursor:pointer;font-size:13px;color:var(--mut);list-style:none;padding:4px 0} + .adv > summary::-webkit-details-marker{display:none} + .adv > summary::before{content:"▸ ";color:var(--dim)} + .adv[open] > summary::before{content:"▾ "} + .adv > summary:hover{color:var(--ink)} + .rt-map{display:grid;gap:4px;margin:0} + .rt-row{display:grid;grid-template-columns:1fr auto;gap:12px;align-items:baseline;padding:7px 10px;border-radius:8px;background:rgba(255,255,255,.03)} + .rt-row code{font-size:12px;justify-self:start} + .rt-row .rt-note{font-size:12px;color:var(--dim)} + .rt-row.rt-over{background:rgba(214,255,61,.08)} + .rt-row.rt-over .rt-note{color:var(--acid)} .empty{padding:26px 20px;text-align:center;color:var(--mut);border:1px dashed var(--line);border-radius:12px;margin-top:10px} /* detail */ @@ -267,7 +280,7 @@ .addrow{grid-template-columns:1fr 1fr} } - + @@ -355,7 +368,7 @@
- + @@ -420,32 +433,63 @@
-