The website sources are grouped under WebsiteDev/. site-theseus-x moves there for real (the other site folders are still junctions from WebsiteDev to the root); a junction at the old root path keeps existing scripts working and is ignored so the files are tracked once. Docs, the icon script and the mirror example follow the new path. whitepaper/whitepaper.md is the Markdown the published page was built from, so the text can be edited and rebuilt without the original document.
239 lines
14 KiB
HTML
239 lines
14 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>theseus.x — Publish an extension</title>
|
||
<meta name="description" content="Publish a Theseus extension: sign the package with the wallet that holds your BCDN name; it is stored on Sia and listed in the catalog.">
|
||
<meta name="robots" content="noindex">
|
||
<link rel="icon" href="/assets/favicon.svg" type="image/svg+xml">
|
||
<style>
|
||
:root{--bg:#0b0e14;--panel:#141a24;--panel2:#18202c;--line:rgba(255,255,255,.09);--ink:#e7eaf1;--mut:#8b98a9;--dim:#5e6678;--acid:#d6ff3d;--ok:#4fd1a5;--bad:#f6768a}
|
||
*{box-sizing:border-box}
|
||
body{margin:0;background:radial-gradient(1100px 560px at 50% -12%,#16202e,var(--bg));color:var(--ink);font:16px/1.65 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;min-height:100vh}
|
||
a{color:var(--acid)}
|
||
.wrap{max-width:760px;margin:0 auto;padding:0 1.2rem 3rem}
|
||
.topnav{position:sticky;top:0;z-index:20;display:flex;gap:2px;align-items:center;flex-wrap:wrap;padding:10px 1.2rem;background:rgba(11,14,20,.82);backdrop-filter:blur(10px);border-bottom:1px solid var(--line)}
|
||
.topnav .brand{font-weight:600;color:var(--ink);text-decoration:none;margin-right:14px;font-size:15px;display:flex;align-items:center;gap:8px}
|
||
.topnav .brand .g{color:var(--acid)}
|
||
.topnav a{color:var(--mut);text-decoration:none;padding:6px 12px;border-radius:8px;font-size:14px}
|
||
.topnav a:hover{color:var(--ink);background:var(--panel)}
|
||
.topnav a.here{color:var(--acid);background:rgba(214,255,61,.08)}
|
||
h1{font-size:clamp(1.6rem,4vw,2.1rem);margin:2.2rem 0 .3rem}
|
||
h1 .g{color:var(--acid)}
|
||
.lede{color:var(--mut);margin:0 0 1.4rem}
|
||
.step{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:18px 20px;margin:1rem 0}
|
||
.step h2{font-size:1.05rem;margin:0 0 .6rem;display:flex;align-items:center;gap:10px}
|
||
.step h2 .n{display:inline-grid;place-items:center;width:24px;height:24px;border-radius:50%;background:var(--acid);color:#0b0e14;font-size:13px;font-weight:700}
|
||
.step h2 .done{color:var(--ok);font-size:13px;font-weight:500;margin-left:auto}
|
||
label{display:block;color:var(--mut);font-size:13px;margin:.6rem 0 .25rem}
|
||
input[type=text],textarea{width:100%;background:#0e131b;border:1px solid var(--line);border-radius:9px;color:var(--ink);padding:9px 11px;font:14px system-ui,sans-serif}
|
||
textarea{font-family:ui-monospace,monospace;font-size:13px;min-height:74px}
|
||
input[type=file]{color:var(--mut);font-size:13px}
|
||
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:.6rem}
|
||
.btn{display:inline-block;padding:.5rem 1rem;border-radius:9px;background:var(--acid);color:#0b0e14;font-weight:650;border:0;cursor:pointer;font:600 14px system-ui,sans-serif}
|
||
.btn.ghost{background:transparent;color:var(--ink);border:1px solid var(--line)}
|
||
.btn:disabled{opacity:.45;cursor:default}
|
||
.st{font-size:13px;color:var(--mut)}
|
||
.st.ok{color:var(--ok)} .st.bad{color:var(--bad)}
|
||
code,.mono{background:#0e131b;border:1px solid var(--line);border-radius:6px;padding:1px 6px;font-family:ui-monospace,monospace;font-size:12.5px;word-break:break-all}
|
||
.kv{display:grid;grid-template-columns:110px 1fr;gap:4px 12px;font-size:13px;margin-top:.6rem}
|
||
.kv b{color:var(--mut);font-weight:600}
|
||
.note{background:var(--panel);border:1px solid var(--line);border-left:3px solid var(--acid);border-radius:10px;padding:12px 16px;color:var(--mut);font-size:13.5px;margin:1.2rem 0}
|
||
.note b{color:var(--ink)}
|
||
.chk{display:flex;gap:8px;align-items:center;color:var(--mut);font-size:13px;margin-top:.6rem}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<nav class="topnav">
|
||
<a class="brand" href="/"><span>theseus<span class="g">.x</span></span></a>
|
||
<a href="/">Home</a>
|
||
<a href="/plug-ins/">Plug-ins</a>
|
||
<a href="../">Extensions</a>
|
||
<a class="here" href="./">Publish</a>
|
||
</nav>
|
||
<div class="wrap">
|
||
<h1>Publish an <span class="g">extension</span></h1>
|
||
<p class="lede">Three steps, all in this tab: unlock the wallet that holds your BCDN name, choose the packaged
|
||
extension, sign. The gateway checks the signature against the name's on-chain owner, stores the package on
|
||
Sia and updates the <a href="../#community">catalog</a>. Your recovery phrase never leaves this page.</p>
|
||
|
||
<div class="step" id="s1">
|
||
<h2><span class="n">1</span> Wallet <span class="done" id="s1done" hidden>✓ unlocked</span></h2>
|
||
<div id="walletForm">
|
||
<label for="phrase">Recovery phrase of the wallet that holds your name (12 or 24 words)</label>
|
||
<textarea id="phrase" spellcheck="false" autocomplete="off" placeholder="word word word …"></textarea>
|
||
<label class="chk"><input type="checkbox" id="stay" checked> Stay signed in on this device (encrypted with a key only this browser profile can use)</label>
|
||
<div class="row"><button class="btn" id="unlock">Unlock</button><span class="st" id="walletSt"></span></div>
|
||
</div>
|
||
<div id="walletInfo" hidden>
|
||
<div class="kv"><b>Address</b><span class="mono" id="addr"></span></div>
|
||
<div class="row"><button class="btn ghost" id="signout">Sign out</button></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="step" id="s2">
|
||
<h2><span class="n">2</span> Publisher name <span class="done" id="s2done" hidden>✓ you own it</span></h2>
|
||
<label for="pname">A BCDN name you own (e.g. <code>you.bch</code>) — it becomes the publisher of record</label>
|
||
<div class="row"><input type="text" id="pname" placeholder="name.tld" style="max-width:320px" spellcheck="false"><button class="btn ghost" id="checkName">Check ownership</button></div>
|
||
<div class="st" id="nameSt"></div>
|
||
</div>
|
||
|
||
<div class="step" id="s3">
|
||
<h2><span class="n">3</span> Package <span class="done" id="s3done" hidden>✓ looks valid</span></h2>
|
||
<label>Gzipped tar of the extension folder (<code>tar -czf my-ext-1.0.0.tar.gz -C my-ext .</code>, max 8 MB)</label>
|
||
<input type="file" id="pkg" accept=".tar.gz,.tgz,application/gzip">
|
||
<div class="kv" id="pkgInfo" hidden>
|
||
<b>id</b><span id="pid"></span>
|
||
<b>name</b><span id="pnameOut"></span>
|
||
<b>version</b><span id="pver"></span>
|
||
<b>main</b><span id="pmain"></span>
|
||
<b>files</b><span id="pfiles"></span>
|
||
<b>sha256</b><span class="mono" id="psha"></span>
|
||
</div>
|
||
<div class="st" id="pkgSt"></div>
|
||
</div>
|
||
|
||
<div class="step" id="s4">
|
||
<h2><span class="n">4</span> Sign and publish</h2>
|
||
<p class="st">Two signatures are made with your wallet key: one authorises this upload, the other is stored in the
|
||
extension's channel so every Theseus can verify the package came from your name.</p>
|
||
<div class="row"><button class="btn" id="publish" disabled>Sign & publish</button><span class="st" id="pubSt"></span></div>
|
||
<div id="result" hidden>
|
||
<div class="kv">
|
||
<b>Catalog</b><span><a href="../#community">theseus.x/extensions</a></span>
|
||
<b>Channel</b><span class="mono" id="rUpdates"></span>
|
||
<b>Package</b><span class="mono" id="rTarball"></span>
|
||
</div>
|
||
<p class="st" style="margin-top:.6rem">Install it from Theseus › Settings › Extensions › Community. New versions: come back with a higher version number.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="note"><b>What the gateway enforces.</b> The signature must recover to the name's current NFT owner ·
|
||
<code>addon.json</code> at the package root with matching <code>id</code>/<code>version</code> and a <code>main</code> script ·
|
||
an id belongs to the first name that publishes it · each new version must be higher than the last · 8 MB cap.</div>
|
||
</div>
|
||
|
||
<script src="../js/session.js"></script>
|
||
<script type="module">
|
||
import * as BNS from "../js/bns-register.js";
|
||
const API = "https://silentmode.st";
|
||
const NAME_API = "https://navigate.st";
|
||
const $ = (id) => document.getElementById(id);
|
||
const st = (id, text, cls = "") => { const el = $(id); el.textContent = text; el.className = "st " + cls; };
|
||
const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||
const b64 = (u8) => btoa(String.fromCharCode(...u8));
|
||
let wallet = null, pubName = "", pkgBytes = null, pkgSha = "", manifest = null;
|
||
|
||
function showWallet() {
|
||
$("walletForm").hidden = !!wallet; $("walletInfo").hidden = !wallet; $("s1done").hidden = !wallet;
|
||
if (wallet) $("addr").textContent = wallet.address;
|
||
gate();
|
||
}
|
||
function gate() { $("publish").disabled = !(wallet && pubName && pkgBytes && manifest); }
|
||
|
||
// --- 1. wallet ---
|
||
(async () => {
|
||
try {
|
||
const S = window.siriusSession;
|
||
const s = S?.restore ? await S.restore() : null;
|
||
if (s?.mnemonic) { wallet = await BNS.BuiltInWallet.fromMnemonic(s.mnemonic, BNS.CHIPNET_PREFIX, s.accountPath || undefined); showWallet(); }
|
||
} catch {}
|
||
})();
|
||
$("unlock").onclick = async () => {
|
||
const phrase = $("phrase").value.trim().toLowerCase().replace(/\s+/g, " ");
|
||
const n = phrase.split(" ").length;
|
||
if (n !== 12 && n !== 24) return st("walletSt", "A recovery phrase has 12 or 24 words.", "bad");
|
||
st("walletSt", "Deriving…");
|
||
try {
|
||
wallet = await BNS.BuiltInWallet.fromMnemonic(phrase, BNS.CHIPNET_PREFIX);
|
||
if ($("stay").checked && window.siriusSession?.remember) await window.siriusSession.remember(phrase);
|
||
$("phrase").value = "";
|
||
st("walletSt", ""); showWallet();
|
||
} catch (e) { st("walletSt", "Could not derive a wallet: " + (e?.message || e), "bad"); }
|
||
};
|
||
$("signout").onclick = async () => { wallet = null; try { await window.siriusSession?.forget(); } catch {} showWallet(); };
|
||
|
||
// --- 2. name ownership ---
|
||
$("checkName").onclick = async () => {
|
||
const name = $("pname").value.trim().toLowerCase();
|
||
pubName = ""; $("s2done").hidden = true; gate();
|
||
if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(name)) return st("nameSt", "Enter a registered name like you.bch.", "bad");
|
||
if (!wallet) return st("nameSt", "Unlock the wallet first.", "bad");
|
||
st("nameSt", "Looking up the owner…");
|
||
try {
|
||
const r = await fetch(`${NAME_API}/api/name/${encodeURIComponent(name)}`, { cache: "no-store" });
|
||
const j = await r.json();
|
||
if (!j.registered) return st("nameSt", `${name} is not registered.`, "bad");
|
||
if (!j.owner) return st("nameSt", "The index has no owner for this name yet — try again in a minute.", "bad");
|
||
if (j.owner !== wallet.address) return st("nameSt", `${name} is owned by ${j.owner}, not by this wallet.`, "bad");
|
||
pubName = name; $("s2done").hidden = false; st("nameSt", "This wallet holds " + name + ".", "ok"); gate();
|
||
} catch (e) { st("nameSt", "Lookup failed: " + (e?.message || e), "bad"); }
|
||
};
|
||
|
||
// --- 3. package: sha256 + read addon.json out of the gzipped tar ---
|
||
async function gunzip(bytes) {
|
||
const ds = new DecompressionStream("gzip");
|
||
const out = new Blob([bytes]).stream().pipeThrough(ds);
|
||
return new Uint8Array(await new Response(out).arrayBuffer());
|
||
}
|
||
function tarFiles(raw) {
|
||
const files = []; const td = new TextDecoder();
|
||
const cstr = (a, b) => td.decode(raw.subarray(a, b)).replace(/\0[\s\S]*$/, "");
|
||
for (let off = 0; off + 512 <= raw.length; ) {
|
||
const hdr = raw.subarray(off, off + 512);
|
||
if (hdr.every((x) => x === 0)) break;
|
||
let name = cstr(off, off + 100);
|
||
const size = parseInt(cstr(off + 124, off + 136).trim() || "0", 8) || 0;
|
||
const type = String.fromCharCode(hdr[156] || 48);
|
||
if (cstr(off + 257, off + 263) === "ustar") { const pre = cstr(off + 345, off + 500); if (pre) name = pre + "/" + name; }
|
||
name = name.replace(/^\.\//, "");
|
||
if (type === "0" || type === "\0") files.push({ name, data: raw.subarray(off + 512, off + 512 + size) });
|
||
off += 512 + Math.ceil(size / 512) * 512;
|
||
}
|
||
return files;
|
||
}
|
||
$("pkg").onchange = async () => {
|
||
pkgBytes = null; manifest = null; $("pkgInfo").hidden = true; $("s3done").hidden = true; gate();
|
||
const f = $("pkg").files[0]; if (!f) return;
|
||
if (f.size > 8 * 1024 * 1024) return st("pkgSt", "Package is over 8 MB.", "bad");
|
||
try {
|
||
const bytes = new Uint8Array(await f.arrayBuffer());
|
||
const files = tarFiles(await gunzip(bytes));
|
||
const mf = files.filter((x) => x.name === "addon.json" || /^[^/]+\/addon\.json$/.test(x.name)).sort((a, b) => a.name.length - b.name.length)[0];
|
||
if (!mf) return st("pkgSt", "No addon.json at the package root.", "bad");
|
||
const root = mf.name === "addon.json" ? "" : mf.name.slice(0, -"addon.json".length);
|
||
const m = JSON.parse(new TextDecoder().decode(mf.data));
|
||
if (!/^[a-z0-9][a-z0-9._-]{1,63}$/.test(m.id || "")) return st("pkgSt", "addon.json id must be 2–64 chars of [a-z0-9._-].", "bad");
|
||
if (!/^\d+(\.\d+){1,3}$/.test(m.version || "")) return st("pkgSt", "addon.json version must look like 1.2.3.", "bad");
|
||
if (!m.main || !files.some((x) => x.name === root + String(m.main).replace(/^\.\//, ""))) return st("pkgSt", `main script "${m.main}" is not in the package.`, "bad");
|
||
if (!m.name) return st("pkgSt", "addon.json needs a name.", "bad");
|
||
pkgBytes = bytes; manifest = m; pkgSha = hex(await crypto.subtle.digest("SHA-256", bytes));
|
||
$("pid").textContent = m.id; $("pnameOut").textContent = m.name; $("pver").textContent = m.version; $("pmain").textContent = m.main;
|
||
$("pfiles").textContent = files.length + " files, " + (bytes.length / 1024).toFixed(1) + " KB"; $("psha").textContent = pkgSha;
|
||
$("pkgInfo").hidden = false; $("s3done").hidden = false; st("pkgSt", ""); gate();
|
||
} catch (e) { st("pkgSt", "Could not read the package: " + (e?.message || e), "bad"); }
|
||
};
|
||
|
||
// --- 4. sign + PUT ---
|
||
$("publish").onclick = async () => {
|
||
if (!(wallet && pubName && pkgBytes && manifest)) return;
|
||
$("publish").disabled = true; st("pubSt", "Signing…");
|
||
try {
|
||
const ts = String(Date.now());
|
||
const digest = async (msg) => new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(msg)));
|
||
const sig = b64(wallet.signMessage(await digest(`BNS-EXT1\n${pubName}\n${manifest.id}\n${manifest.version}\n${pkgSha}\n${ts}`)));
|
||
const entrySig = b64(wallet.signMessage(await digest(`silentmode.extension-v1|${manifest.id}|${manifest.version}|${pkgSha}|${pubName}`)));
|
||
st("pubSt", "Uploading to the gateway…");
|
||
const r = await fetch(`${API}/api/ext/${encodeURIComponent(pubName)}/${manifest.id}/${manifest.version}`, {
|
||
method: "PUT", body: pkgBytes, headers: { "content-type": "application/gzip", "x-bns-ts": ts, "x-bns-sig": sig, "x-bns-entry-sig": entrySig },
|
||
});
|
||
const j = await r.json().catch(() => ({}));
|
||
if (!r.ok) throw new Error(j.error || ("HTTP " + r.status));
|
||
st("pubSt", `Published ${j.id} v${j.version} as ${j.publisher}.`, "ok");
|
||
$("rUpdates").textContent = j.updates; $("rTarball").textContent = j.tarball; $("result").hidden = false;
|
||
} catch (e) { st("pubSt", "Publish failed: " + (e?.message || e), "bad"); $("publish").disabled = false; }
|
||
};
|
||
</script>
|
||
</body>
|
||
</html>
|