feat: community extensions — publish with a BCDN name, install from Settings, theseus.x catalog
Anyone who owns a BCDN name can now publish a Theseus extension, and every Theseus can install it with the publisher's signature verified locally. Gateway (Argus/src/gateway/public-gateway.mjs): PUT /api/ext/<name>/<id>/<version> takes the gzipped tar, checks two BCH message signatures against the name's current NFT owner (one authorises the upload, one is stored in the channel), inspects the package (addon.json at the root, id/version/main match, 8 MB cap), enforces first-publisher ownership of an id and monotonic versions, and writes the tarball, the extension's updates.json and community/catalog.json to Sia. GET /api/ext/catalog reads the catalog back with CORS. Theseus: lib/publisher-sig.mjs recovers the signer of a channel entry; main.js compares it with the publisher name's owner from Theseus's own chain index before installing or updating, so neither the relay nor a tampered catalog can pass off code under a trusted name. addon-updater.js gains installCommunity() and accepts publisher-signed entries in the regular update check (operator Ed25519 entries unchanged). Settings › Extensions shows the community catalog with Install / Update; Settings › Plug-ins links to theseus.x/plug-ins. theseus.x: /plug-ins/ is a separate page for the first-party plug-ins (Aegis, Ariadne's Thread) with live versions and hashes; /extensions/ lists the bundled extensions, the community catalog, and how to build and publish; /extensions/publish/ signs and uploads a package in the browser with the wallet that holds the publisher's name (session helper + wallet bundle copied alongside).
This commit is contained in:
parent
bdb4c6235d
commit
527cae9d32
1 changed files with 154 additions and 0 deletions
|
|
@ -15,6 +15,7 @@ import https from "https";
|
|||
import tls from "tls";
|
||||
import { AwsClient } from "aws4fetch";
|
||||
import { createHash } from "node:crypto";
|
||||
import { gunzipSync } from "node:zlib";
|
||||
// libauth is present so the next drop's signature-verify path is a
|
||||
// contained edit (no dep install between now and then). Only utf8ToBin
|
||||
// and sha256 are used today; the rest are named here so the imports
|
||||
|
|
@ -1342,6 +1343,159 @@ const server = http.createServer(async (req, res) => {
|
|||
} catch (e) { res.writeHead(500); res.end(JSON.stringify({ error: `site write error: ${e.message}` })); }
|
||||
return;
|
||||
}
|
||||
// --- community extensions (publisher-signed, Sia-hosted) -------------------
|
||||
// GET /api/ext/catalog -> catalog.json (read-through, CORS)
|
||||
// PUT /api/ext/<publisher-name>/<id>/<version> -> body = gzipped tar of the add-on folder
|
||||
// x-bns-ts unix ms, within 10 minutes of now
|
||||
// x-bns-sig base64 65-byte BCH message signature over
|
||||
// sha256("BNS-EXT1\n<name>\n<id>\n<version>\n<sha256hex(body)>\n<ts>")
|
||||
// x-bns-entry-sig base64 65-byte signature over
|
||||
// sha256("silentmode.extension-v1|<id>|<version>|<sha256hex(body)>|<name>")
|
||||
// Both must recover to the publisher name's current on-chain NFT owner —
|
||||
// the same check /api/site and /api/records make. The first authorises
|
||||
// this upload; the second is STORED in the extension's updates.json so
|
||||
// every client (Theseus) can re-verify the entry against the owner
|
||||
// without trusting this gateway. An extension id belongs to the first
|
||||
// name that publishes it; later versions must come from that name.
|
||||
// Layout on Sia (served as https://navigate.st/bns/theseus.x/extensions/community/…):
|
||||
// community/catalog.json { updated, extensions: [...] }
|
||||
// community/<id>/updates.json { id, publisher, addons: [ {version,url,sha256,publisher,owner,publisherSig,publishedAt} ] }
|
||||
// community/<id>/<id>-<version>.tar.gz
|
||||
if (req.url.startsWith("/api/ext/")) {
|
||||
res.setHeader("access-control-allow-origin", "*");
|
||||
res.setHeader("access-control-allow-methods", "GET, PUT, OPTIONS");
|
||||
res.setHeader("access-control-allow-headers", "content-type, x-bns-sig, x-bns-ts, x-bns-entry-sig");
|
||||
res.setHeader("access-control-max-age", "86400");
|
||||
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
|
||||
res.setHeader("content-type", "application/json");
|
||||
const EXT_PREFIX = "bns/theseus/extensions/community/";
|
||||
const EXT_PUBLIC = "https://navigate.st/bns/theseus.x/extensions/community/";
|
||||
const fail = (code, error, extra) => { res.writeHead(code); res.end(JSON.stringify({ error, ...(extra || {}) })); };
|
||||
const readJson = async (key, fallback) => {
|
||||
const up = await aws.fetch(`${S3_ENDPOINT}/${key}`);
|
||||
if (up.status === 404 || up.status === 403) return fallback;
|
||||
if (!up.ok) throw new Error(`sia read failed: ${up.status}`);
|
||||
try { return JSON.parse(await up.text()); } catch { return fallback; }
|
||||
};
|
||||
const writeObj = async (key, body, ct) => {
|
||||
const put = await aws.fetch(`${S3_ENDPOINT}/${key}`, { method: "PUT", body, headers: { "content-type": ct } });
|
||||
if (!put.ok) throw new Error(`sia write failed: ${put.status} ${(await put.text().catch(() => "")).slice(0, 200)}`);
|
||||
};
|
||||
const rest = req.url.slice("/api/ext/".length).split("?")[0];
|
||||
if (rest === "catalog" || rest === "catalog.json") {
|
||||
if (req.method !== "GET") return fail(405, "GET");
|
||||
try {
|
||||
const cat = await readJson(EXT_PREFIX + "catalog.json", { updated: null, extensions: [] });
|
||||
res.setHeader("cache-control", "public, max-age=30");
|
||||
res.writeHead(200); res.end(JSON.stringify(cat));
|
||||
} catch (e) { fail(502, `sia error: ${e.message}`); }
|
||||
return;
|
||||
}
|
||||
const parts = rest.split("/").map((x) => { try { return decodeURIComponent(x); } catch { return ""; } });
|
||||
if (parts.length !== 3) return fail(404, "PUT /api/ext/<publisher-name>/<id>/<version>");
|
||||
if (req.method !== "PUT") return fail(405, "PUT");
|
||||
const [rawName, id, version] = parts;
|
||||
let name;
|
||||
try { name = normalizeName(rawName); } catch (e) { return fail(400, e.message); }
|
||||
if (!/^[a-z0-9][a-z0-9._-]{1,63}$/.test(id) || id.includes("..")) return fail(400, "extension id must be 2-64 chars of [a-z0-9._-]");
|
||||
if (!/^\d+(\.\d+){1,3}$/.test(version)) return fail(400, "version must look like 1.2.3");
|
||||
const entry = (await getIndex()).get(name);
|
||||
if (!entry) return fail(404, `"${name}" not registered`);
|
||||
if (!entry.owner) return fail(503, "gateway index has no owner recorded for this name yet — retry after the next index refresh");
|
||||
// ---- body (8 MB cap) ----
|
||||
const chunks = []; let total = 0;
|
||||
for await (const c of req) {
|
||||
total += c.length;
|
||||
if (total > 8 * 1024 * 1024) return fail(413, "extension package > 8 MB");
|
||||
chunks.push(c);
|
||||
}
|
||||
const body = Buffer.concat(chunks);
|
||||
if (!body.length) return fail(400, "empty body");
|
||||
const sha256hex = createHash("sha256").update(body).digest("hex");
|
||||
// ---- signatures ----
|
||||
const ts = Number(req.headers["x-bns-ts"] || 0);
|
||||
if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > 10 * 60 * 1000) return fail(401, "x-bns-ts missing or more than 10 minutes off");
|
||||
const uploadMsg = `BNS-EXT1\n${name}\n${id}\n${version}\n${sha256hex}\n${ts}`;
|
||||
const entryMsg = `silentmode.extension-v1|${id}|${version}|${sha256hex}|${name}`;
|
||||
const who = recoverSigner(String(req.headers["x-bns-sig"] || ""), uploadMsg, entry.owner);
|
||||
if (!who) return fail(400, "x-bns-sig is not a valid 65-byte BCH message signature");
|
||||
if (who !== entry.owner) return fail(403, "x-bns-sig does not match the current on-chain NFT owner of the publisher name", { derived: who, owner: entry.owner });
|
||||
const entrySig = String(req.headers["x-bns-entry-sig"] || "");
|
||||
const whoEntry = recoverSigner(entrySig, entryMsg, entry.owner);
|
||||
if (whoEntry !== entry.owner) return fail(403, "x-bns-entry-sig missing or not signed by the publisher name's owner");
|
||||
// ---- package inspection: gzipped tar with addon.json at its root ----
|
||||
let files;
|
||||
try {
|
||||
const raw = gunzipSync(body, { maxOutputLength: 64 * 1024 * 1024 });
|
||||
files = [];
|
||||
for (let off = 0; off + 512 <= raw.length; ) {
|
||||
const hdr = raw.subarray(off, off + 512);
|
||||
if (hdr.every((b) => b === 0)) break;
|
||||
const cstr = (a, b) => hdr.subarray(a, b).toString("utf8").replace(/\0[\s\S]*$/, "");
|
||||
let fname = cstr(0, 100);
|
||||
const size = parseInt(cstr(124, 136).trim() || "0", 8) || 0;
|
||||
const type = String.fromCharCode(hdr[156] || 48);
|
||||
const ustar = cstr(257, 263) === "ustar";
|
||||
const prefix = ustar ? cstr(345, 500) : "";
|
||||
if (prefix) fname = prefix + "/" + fname;
|
||||
fname = fname.replace(/^\.\//, "");
|
||||
const dataStart = off + 512, dataEnd = dataStart + size;
|
||||
if (dataEnd > raw.length) throw new Error("truncated tar");
|
||||
if (fname.startsWith("/") || fname.split("/").includes("..")) throw new Error(`unsafe path in tar: ${fname}`);
|
||||
if (type === "0" || type === "\0") files.push({ name: fname, data: raw.subarray(dataStart, dataEnd) });
|
||||
else if (type !== "5" && type !== "x" && type !== "g" && type !== "L") throw new Error(`unsupported tar entry type '${type}' for ${fname}`);
|
||||
off = dataStart + Math.ceil(size / 512) * 512;
|
||||
if (files.length > 2000) throw new Error("too many files");
|
||||
}
|
||||
} catch (e) { return fail(400, `package is not a gzipped tar: ${e.message}`); }
|
||||
const manifests = files.filter((f) => f.name === "addon.json" || /^[^/]+\/addon\.json$/.test(f.name)).sort((a, b) => a.name.length - b.name.length);
|
||||
if (!manifests.length) return fail(400, "package has no addon.json at its root (or in a single top-level folder)");
|
||||
const root = manifests[0].name === "addon.json" ? "" : manifests[0].name.slice(0, -"addon.json".length);
|
||||
if (root && files.some((f) => !f.name.startsWith(root))) return fail(400, "package mixes a top-level folder with loose files");
|
||||
let manifest;
|
||||
try { manifest = JSON.parse(manifests[0].data.toString("utf8")); } catch (e) { return fail(400, `addon.json is not JSON: ${e.message}`); }
|
||||
if (manifest.id !== id) return fail(400, `addon.json id "${manifest.id}" does not match URL id "${id}"`);
|
||||
if (manifest.version !== version) return fail(400, `addon.json version "${manifest.version}" does not match URL version "${version}"`);
|
||||
if (typeof manifest.main !== "string" || !manifest.main) return fail(400, "addon.json needs a \"main\" script");
|
||||
if (!files.some((f) => f.name === root + manifest.main.replace(/^\.\//, ""))) return fail(400, `main script "${manifest.main}" is not in the package`);
|
||||
if (typeof manifest.name !== "string" || !manifest.name.trim()) return fail(400, "addon.json needs a \"name\"");
|
||||
// ---- ownership + monotonic version ----
|
||||
let catalog;
|
||||
try { catalog = await readJson(EXT_PREFIX + "catalog.json", { updated: null, extensions: [] }); } catch (e) { return fail(502, `sia error: ${e.message}`); }
|
||||
const list = Array.isArray(catalog.extensions) ? catalog.extensions : [];
|
||||
const existing = list.find((x) => x && x.id === id);
|
||||
if (existing && existing.publisher !== name) return fail(403, `"${id}" is published by ${existing.publisher}; pick another id`);
|
||||
const cmpV = (a, b) => { const A = String(a).split(".").map(Number), B = String(b).split(".").map(Number); for (let i = 0; i < Math.max(A.length, B.length); i++) { const d = (A[i] || 0) - (B[i] || 0); if (d) return d; } return 0; };
|
||||
if (existing && cmpV(version, existing.latest) <= 0) return fail(409, `version must be newer than the published ${existing.latest}`);
|
||||
// ---- write: tarball, updates.json, catalog ----
|
||||
const tarKey = `${EXT_PREFIX}${id}/${id}-${version}.tar.gz`;
|
||||
const tarUrl = `${EXT_PUBLIC}${id}/${id}-${version}.tar.gz`;
|
||||
const updatesKey = `${EXT_PREFIX}${id}/updates.json`;
|
||||
const now = new Date().toISOString();
|
||||
try {
|
||||
await writeObj(tarKey, body, "application/gzip");
|
||||
const upd = await readJson(updatesKey, { addons: [] });
|
||||
const addons = Array.isArray(upd.addons) ? upd.addons.filter((e) => e && e.version !== version) : [];
|
||||
addons.unshift({ version, url: tarUrl, sha256: sha256hex, publisher: name, owner: entry.owner, publisherSig: entrySig, publishedAt: now });
|
||||
addons.sort((a, b) => cmpV(b.version, a.version));
|
||||
await writeObj(updatesKey, JSON.stringify({ id, publisher: name, addons }, null, 2), "application/json");
|
||||
const iconOk = typeof manifest.icon === "string" && manifest.icon.length <= 8 && !/^data:/i.test(manifest.icon);
|
||||
const card = {
|
||||
id, name: String(manifest.name).slice(0, 80), description: String(manifest.description || "").slice(0, 400),
|
||||
icon: iconOk ? manifest.icon : "🧩", author: String(manifest.author || "").slice(0, 80),
|
||||
capabilities: Array.isArray(manifest.capabilities) ? manifest.capabilities.map(String).slice(0, 12) : [],
|
||||
publisher: name, owner: entry.owner, latest: version, sha256: sha256hex,
|
||||
updatesUrl: `${EXT_PUBLIC}${id}/updates.json`, tarballUrl: tarUrl,
|
||||
versions: addons.length, publishedAt: existing?.publishedAt || now, updatedAt: now,
|
||||
};
|
||||
const next = list.filter((x) => x && x.id !== id); next.push(card);
|
||||
next.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
|
||||
await writeObj(EXT_PREFIX + "catalog.json", JSON.stringify({ updated: now, extensions: next }, null, 2), "application/json");
|
||||
for (const k of [...cache.keys()]) if (k.startsWith("theseus.x")) cache.delete(k);
|
||||
res.writeHead(200); res.end(JSON.stringify({ ok: true, id, version, publisher: name, sha256: sha256hex, tarball: tarUrl, updates: card.updatesUrl, catalog: `${EXT_PUBLIC}catalog.json` }));
|
||||
} catch (e) { fail(502, `sia error: ${e.message}`); }
|
||||
return;
|
||||
}
|
||||
// --- resolver-side DNS read (verified) ---
|
||||
// GET /api/dns/<name> -> { name, seq, updated_at, dns, verified_owner }
|
||||
//
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue