diff --git a/src/gateway/public-gateway.mjs b/src/gateway/public-gateway.mjs index 4609acc..ac9f3bf 100644 --- a/src/gateway/public-gateway.mjs +++ b/src/gateway/public-gateway.mjs @@ -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/// -> 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\n\n\n\n") + // x-bns-entry-sig base64 65-byte signature over + // sha256("silentmode.extension-v1||||") + // 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//updates.json { id, publisher, addons: [ {version,url,sha256,publisher,owner,publisherSig,publishedAt} ] } + // community//-.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///"); + 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, seq, updated_at, dns, verified_owner } //