240 lines
14 KiB
HTML
240 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>
|