theseus/addon-updater.js

366 lines
19 KiB
JavaScript
Raw Normal View History

feat(theseus/addons): signed add-on update endpoint, à la Firefox XPI Decouples bundled-add-on updates from Theseus releases. An add-on whose addon.json declares an updateURL can be republished at any time without shipping a new Theseus installer; existing installs pick it up on the next boot's +30 s background check. Client flow (main-process only, no UI touchpoints in this commit): initAddons() ├── promoteStagedUpdates() # promote signed stage if newer ├── seedBundledAddons() # bundle wins over on-disk if newer └── AddonHost.discoverAndActivate() 30 s later: └── checkAndStageUpdates() # fetch, verify, download, stage Signature: Ed25519 over "silentmode.addon-update-v1|<id>|<version>|<tarball-sha256>", verified against a hardcoded set of operator pubkeys living in addon-update-pubkeys.js. Domain-separated so the operator key can't be tricked into signing a message with a different purpose. Empty pubkey array is the shipping default — checkAndStageUpdates() then short-circuits and no outbound requests are made, which is the safe posture until the operator ceremonies a key in. Payload: gzipped tar, extracted with the system tar (present on Win10 1803+, macOS, Linux). Path traversal defended by tar's default refusal of `..` entries; the extracted manifest's id + version are re-checked against the signed values before staging. Staged updates go to <userData>/addons-updates-staged/<id>-<version>/. Promotion into <userData>/addons/<id>/ reuses seedBundledAddons's backup dance: existing folder moves to <userData>/addons-backups/<id>-<oldver>-<timestamp>/ so any local edits survive. New files: - addon-updater.js — client - addon-update-pubkeys.js — hardcoded pubkeys (empty; edit + rebuild to rotate) - scripts/generate-update-keypair.mjs — one-time keygen - scripts/sign-addon-update.mjs — operator packager+signer - docs/ADDON-UPDATES.md — operator brief + threat model Wired into main.js at boot; screenshot add-on's addon.json advertises the reference updateURL for when the endpoint goes live.
2026-09-07 21:58:30 +02:00
// Signed add-on update client.
//
// A bundled add-on can advertise an updateURL in its addon.json. On a
// background boot task, Theseus fetches that URL, expects a signed
// updates.json manifest, verifies the signature against a hardcoded set
// of operator pubkeys (addon-update-pubkeys.js), and stages any newer
// signed version under <userData>/addons-updates-staged/<id>-<version>/.
// The NEXT launch's promoteStagedUpdates() moves the staged copy into
// <userData>/addons/<id>/, reusing the same backup dance seedBundledAddons
// already uses so any local edits the user made survive.
//
// Trust model:
// - Signature: Ed25519 over "silentmode.addon-update-v1|<id>|<version>|
// <tarball-sha256>", verified against any pubkey in PUBKEYS_HEX.
// - Payload: gzipped tar. SHA-256 is checked against the signed manifest
// before extraction. Extraction uses the system `tar` (shipped with
// Win10 1803+, present on macOS/Linux) via execFileSync; no npm deps.
// - The extracted addon.json's id + version must match the manifest,
// otherwise the stage is discarded.
// - Empty pubkey list = skip everything, no outbound requests.
const fs = require("node:fs");
const fsp = require("node:fs/promises");
const path = require("node:path");
const os = require("node:os");
const crypto = require("node:crypto");
const https = require("node:https");
const http = require("node:http");
const { execFileSync } = require("node:child_process");
const SIG_DOMAIN = "silentmode.addon-update-v1";
const MAX_MANIFEST_BYTES = 128 * 1024; // updates.json shouldn't exceed 128 KB
const MAX_TARBALL_BYTES = 16 * 1024 * 1024; // an add-on payload above 16 MB is suspicious
const MAX_REDIRECTS = 3;
function cmpVer(a, b) {
const A = String(a || "").split(".").map((n) => parseInt(n, 10) || 0);
const B = String(b || "").split(".").map((n) => parseInt(n, 10) || 0);
const L = Math.max(A.length, B.length);
for (let i = 0; i < L; i++) {
const x = A[i] || 0, y = B[i] || 0;
if (x !== y) return x < y ? -1 : 1;
}
return 0;
}
function readAddonJson(dir) {
try { return JSON.parse(fs.readFileSync(path.join(dir, "addon.json"), "utf8")); }
catch { return null; }
}
// ---------- staged-update promotion --------------------------------------
// Called BEFORE seedBundledAddons at boot. Any staged folder whose version
// beats the currently installed copy is promoted; older or matching stages
// are cleaned up so they don't loop on every boot.
function promoteStagedUpdates({ addonsDir, backupsDir, stagedDir, logger }) {
const log = logger || (() => {});
if (!fs.existsSync(stagedDir)) return;
let entries;
try { entries = fs.readdirSync(stagedDir, { withFileTypes: true }); }
catch (e) { log("promote: readdir failed:", e?.message); return; }
for (const de of entries) {
if (!de.isDirectory()) continue;
const from = path.join(stagedDir, de.name);
const manifest = readAddonJson(from);
if (!manifest || !manifest.id || !manifest.version) {
log(`promote: drop malformed stage ${de.name}`);
try { fs.rmSync(from, { recursive: true, force: true }); } catch {}
continue;
}
const target = path.join(addonsDir, manifest.id);
const currentVer = readAddonJson(target)?.version;
if (currentVer && cmpVer(currentVer, manifest.version) >= 0) {
log(`promote: drop stage ${manifest.id}@${manifest.version} — installed ${currentVer} is newer or equal`);
try { fs.rmSync(from, { recursive: true, force: true }); } catch {}
continue;
}
try { fs.mkdirSync(backupsDir, { recursive: true }); } catch {}
if (fs.existsSync(target)) {
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const backup = path.join(backupsDir, `${manifest.id}-${currentVer ?? "unknown"}-${stamp}`);
try { fs.renameSync(target, backup); }
catch (e) { log(`promote: backup ${manifest.id} failed, keeping staged for next boot:`, e?.message); continue; }
}
try { fs.renameSync(from, target); log(`promote: ${manifest.id} -> ${manifest.version}`); }
catch (e) {
// Cross-drive rename can fail on Windows; copy then rm.
try { fs.cpSync(from, target, { recursive: true }); fs.rmSync(from, { recursive: true, force: true }); log(`promote: ${manifest.id} -> ${manifest.version} (via copy)`); }
catch (ee) { log(`promote: move ${manifest.id} failed:`, ee.message); }
}
}
}
// ---------- HTTP helpers --------------------------------------------------
function httpGet(url, { timeoutMs = 15000, maxBytes = MAX_MANIFEST_BYTES, redirectsLeft = MAX_REDIRECTS } = {}) {
return new Promise((resolve, reject) => {
let u;
try { u = new URL(url); } catch (e) { return reject(new Error(`bad url: ${url}`)); }
if (u.protocol !== "http:" && u.protocol !== "https:") return reject(new Error(`unsupported scheme: ${u.protocol}`));
const lib = u.protocol === "http:" ? http : https;
const req = lib.get(u, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
if (redirectsLeft <= 0) return reject(new Error(`too many redirects for ${url}`));
const next = new URL(res.headers.location, url).toString();
return resolve(httpGet(next, { timeoutMs, maxBytes, redirectsLeft: redirectsLeft - 1 }));
}
if (res.statusCode !== 200) { res.resume(); return reject(new Error(`HTTP ${res.statusCode} for ${url}`)); }
const chunks = [];
let total = 0;
res.on("data", (c) => {
total += c.length;
if (total > maxBytes) { req.destroy(new Error(`response over ${maxBytes} bytes`)); return; }
chunks.push(c);
});
res.on("end", () => resolve(Buffer.concat(chunks)));
res.on("error", reject);
});
req.setTimeout(timeoutMs, () => req.destroy(new Error(`timeout ${timeoutMs}ms for ${url}`)));
req.on("error", reject);
});
}
// ---------- Ed25519 signature verification -------------------------------
// Node accepts raw Ed25519 pubkeys via SPKI-wrapped DER. We prepend the
// 12-byte header for the OID + BIT STRING framing so verify() takes it.
function verifySignature(id, version, tarballSha256, sigB64, pubkeysHex) {
const canonical = `${SIG_DOMAIN}|${id}|${version}|${tarballSha256}`;
let sig;
try { sig = Buffer.from(sigB64, "base64"); } catch { return false; }
if (sig.length !== 64) return false;
const msg = Buffer.from(canonical);
for (const hex of pubkeysHex) {
const pkRaw = Buffer.from(hex, "hex");
if (pkRaw.length !== 32) continue;
const der = Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), pkRaw]);
let key;
try { key = crypto.createPublicKey({ key: der, format: "der", type: "spki" }); }
catch { continue; }
try { if (crypto.verify(null, msg, key, sig)) return true; } catch { /* next */ }
}
return false;
}
// ---------- single-add-on staging ----------------------------------------
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).
2026-09-20 15:26:30 +02:00
// Read a channel manifest and pick its best entry. An entry is trusted when
// EITHER the operator signed it (Ed25519 `sig`, bundled add-ons) OR the
// publisher signed it (`publisherSig`, community extensions — verified by the
// caller-supplied verifyPublisher against the publisher name's NFT owner).
async function pickChannelEntry({ id, currentVer, updateURL, pubkeysHex, verifyPublisher, log, timeoutMs }) {
feat(theseus/addons): signed add-on update endpoint, à la Firefox XPI Decouples bundled-add-on updates from Theseus releases. An add-on whose addon.json declares an updateURL can be republished at any time without shipping a new Theseus installer; existing installs pick it up on the next boot's +30 s background check. Client flow (main-process only, no UI touchpoints in this commit): initAddons() ├── promoteStagedUpdates() # promote signed stage if newer ├── seedBundledAddons() # bundle wins over on-disk if newer └── AddonHost.discoverAndActivate() 30 s later: └── checkAndStageUpdates() # fetch, verify, download, stage Signature: Ed25519 over "silentmode.addon-update-v1|<id>|<version>|<tarball-sha256>", verified against a hardcoded set of operator pubkeys living in addon-update-pubkeys.js. Domain-separated so the operator key can't be tricked into signing a message with a different purpose. Empty pubkey array is the shipping default — checkAndStageUpdates() then short-circuits and no outbound requests are made, which is the safe posture until the operator ceremonies a key in. Payload: gzipped tar, extracted with the system tar (present on Win10 1803+, macOS, Linux). Path traversal defended by tar's default refusal of `..` entries; the extracted manifest's id + version are re-checked against the signed values before staging. Staged updates go to <userData>/addons-updates-staged/<id>-<version>/. Promotion into <userData>/addons/<id>/ reuses seedBundledAddons's backup dance: existing folder moves to <userData>/addons-backups/<id>-<oldver>-<timestamp>/ so any local edits survive. New files: - addon-updater.js — client - addon-update-pubkeys.js — hardcoded pubkeys (empty; edit + rebuild to rotate) - scripts/generate-update-keypair.mjs — one-time keygen - scripts/sign-addon-update.mjs — operator packager+signer - docs/ADDON-UPDATES.md — operator brief + threat model Wired into main.js at boot; screenshot add-on's addon.json advertises the reference updateURL for when the endpoint goes live.
2026-09-07 21:58:30 +02:00
let manifestBuf;
try { manifestBuf = await httpGet(updateURL, { timeoutMs, maxBytes: MAX_MANIFEST_BYTES }); }
catch (e) { log(`updates: fetch ${id} failed:`, e.message); return { status: "fetch-failed", detail: e.message }; }
feat(theseus/addons): signed add-on update endpoint, à la Firefox XPI Decouples bundled-add-on updates from Theseus releases. An add-on whose addon.json declares an updateURL can be republished at any time without shipping a new Theseus installer; existing installs pick it up on the next boot's +30 s background check. Client flow (main-process only, no UI touchpoints in this commit): initAddons() ├── promoteStagedUpdates() # promote signed stage if newer ├── seedBundledAddons() # bundle wins over on-disk if newer └── AddonHost.discoverAndActivate() 30 s later: └── checkAndStageUpdates() # fetch, verify, download, stage Signature: Ed25519 over "silentmode.addon-update-v1|<id>|<version>|<tarball-sha256>", verified against a hardcoded set of operator pubkeys living in addon-update-pubkeys.js. Domain-separated so the operator key can't be tricked into signing a message with a different purpose. Empty pubkey array is the shipping default — checkAndStageUpdates() then short-circuits and no outbound requests are made, which is the safe posture until the operator ceremonies a key in. Payload: gzipped tar, extracted with the system tar (present on Win10 1803+, macOS, Linux). Path traversal defended by tar's default refusal of `..` entries; the extracted manifest's id + version are re-checked against the signed values before staging. Staged updates go to <userData>/addons-updates-staged/<id>-<version>/. Promotion into <userData>/addons/<id>/ reuses seedBundledAddons's backup dance: existing folder moves to <userData>/addons-backups/<id>-<oldver>-<timestamp>/ so any local edits survive. New files: - addon-updater.js — client - addon-update-pubkeys.js — hardcoded pubkeys (empty; edit + rebuild to rotate) - scripts/generate-update-keypair.mjs — one-time keygen - scripts/sign-addon-update.mjs — operator packager+signer - docs/ADDON-UPDATES.md — operator brief + threat model Wired into main.js at boot; screenshot add-on's addon.json advertises the reference updateURL for when the endpoint goes live.
2026-09-07 21:58:30 +02:00
let manifest;
try { manifest = JSON.parse(manifestBuf.toString("utf8")); }
catch (e) { log(`updates: manifest ${id} unparseable:`, e.message); return { status: "fetch-failed", detail: "manifest not JSON: " + e.message }; }
feat(theseus/addons): signed add-on update endpoint, à la Firefox XPI Decouples bundled-add-on updates from Theseus releases. An add-on whose addon.json declares an updateURL can be republished at any time without shipping a new Theseus installer; existing installs pick it up on the next boot's +30 s background check. Client flow (main-process only, no UI touchpoints in this commit): initAddons() ├── promoteStagedUpdates() # promote signed stage if newer ├── seedBundledAddons() # bundle wins over on-disk if newer └── AddonHost.discoverAndActivate() 30 s later: └── checkAndStageUpdates() # fetch, verify, download, stage Signature: Ed25519 over "silentmode.addon-update-v1|<id>|<version>|<tarball-sha256>", verified against a hardcoded set of operator pubkeys living in addon-update-pubkeys.js. Domain-separated so the operator key can't be tricked into signing a message with a different purpose. Empty pubkey array is the shipping default — checkAndStageUpdates() then short-circuits and no outbound requests are made, which is the safe posture until the operator ceremonies a key in. Payload: gzipped tar, extracted with the system tar (present on Win10 1803+, macOS, Linux). Path traversal defended by tar's default refusal of `..` entries; the extracted manifest's id + version are re-checked against the signed values before staging. Staged updates go to <userData>/addons-updates-staged/<id>-<version>/. Promotion into <userData>/addons/<id>/ reuses seedBundledAddons's backup dance: existing folder moves to <userData>/addons-backups/<id>-<oldver>-<timestamp>/ so any local edits survive. New files: - addon-updater.js — client - addon-update-pubkeys.js — hardcoded pubkeys (empty; edit + rebuild to rotate) - scripts/generate-update-keypair.mjs — one-time keygen - scripts/sign-addon-update.mjs — operator packager+signer - docs/ADDON-UPDATES.md — operator brief + threat model Wired into main.js at boot; screenshot add-on's addon.json advertises the reference updateURL for when the endpoint goes live.
2026-09-07 21:58:30 +02:00
const addons = Array.isArray(manifest?.addons) ? manifest.addons : [];
let best = null;
for (const e of addons) {
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).
2026-09-20 15:26:30 +02:00
if (!e?.version || !e?.url || !e?.sha256 || !(e?.sig || e?.publisherSig)) continue;
feat(theseus/addons): signed add-on update endpoint, à la Firefox XPI Decouples bundled-add-on updates from Theseus releases. An add-on whose addon.json declares an updateURL can be republished at any time without shipping a new Theseus installer; existing installs pick it up on the next boot's +30 s background check. Client flow (main-process only, no UI touchpoints in this commit): initAddons() ├── promoteStagedUpdates() # promote signed stage if newer ├── seedBundledAddons() # bundle wins over on-disk if newer └── AddonHost.discoverAndActivate() 30 s later: └── checkAndStageUpdates() # fetch, verify, download, stage Signature: Ed25519 over "silentmode.addon-update-v1|<id>|<version>|<tarball-sha256>", verified against a hardcoded set of operator pubkeys living in addon-update-pubkeys.js. Domain-separated so the operator key can't be tricked into signing a message with a different purpose. Empty pubkey array is the shipping default — checkAndStageUpdates() then short-circuits and no outbound requests are made, which is the safe posture until the operator ceremonies a key in. Payload: gzipped tar, extracted with the system tar (present on Win10 1803+, macOS, Linux). Path traversal defended by tar's default refusal of `..` entries; the extracted manifest's id + version are re-checked against the signed values before staging. Staged updates go to <userData>/addons-updates-staged/<id>-<version>/. Promotion into <userData>/addons/<id>/ reuses seedBundledAddons's backup dance: existing folder moves to <userData>/addons-backups/<id>-<oldver>-<timestamp>/ so any local edits survive. New files: - addon-updater.js — client - addon-update-pubkeys.js — hardcoded pubkeys (empty; edit + rebuild to rotate) - scripts/generate-update-keypair.mjs — one-time keygen - scripts/sign-addon-update.mjs — operator packager+signer - docs/ADDON-UPDATES.md — operator brief + threat model Wired into main.js at boot; screenshot add-on's addon.json advertises the reference updateURL for when the endpoint goes live.
2026-09-07 21:58:30 +02:00
if (currentVer && cmpVer(e.version, currentVer) <= 0) continue;
if (!best || cmpVer(e.version, best.version) > 0) best = e;
}
if (!best) return { status: "up-to-date" };
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).
2026-09-20 15:26:30 +02:00
let trusted = false;
if (best.sig && Array.isArray(pubkeysHex) && pubkeysHex.length) trusted = verifySignature(id, best.version, best.sha256, best.sig, pubkeysHex);
if (!trusted && best.publisherSig && typeof verifyPublisher === "function") {
try { trusted = !!(await verifyPublisher({ ...best, id })); } catch (e) { log(`updates: publisher verify ${id}@${best.version} threw:`, e.message); }
}
if (!trusted) {
feat(theseus/addons): signed add-on update endpoint, à la Firefox XPI Decouples bundled-add-on updates from Theseus releases. An add-on whose addon.json declares an updateURL can be republished at any time without shipping a new Theseus installer; existing installs pick it up on the next boot's +30 s background check. Client flow (main-process only, no UI touchpoints in this commit): initAddons() ├── promoteStagedUpdates() # promote signed stage if newer ├── seedBundledAddons() # bundle wins over on-disk if newer └── AddonHost.discoverAndActivate() 30 s later: └── checkAndStageUpdates() # fetch, verify, download, stage Signature: Ed25519 over "silentmode.addon-update-v1|<id>|<version>|<tarball-sha256>", verified against a hardcoded set of operator pubkeys living in addon-update-pubkeys.js. Domain-separated so the operator key can't be tricked into signing a message with a different purpose. Empty pubkey array is the shipping default — checkAndStageUpdates() then short-circuits and no outbound requests are made, which is the safe posture until the operator ceremonies a key in. Payload: gzipped tar, extracted with the system tar (present on Win10 1803+, macOS, Linux). Path traversal defended by tar's default refusal of `..` entries; the extracted manifest's id + version are re-checked against the signed values before staging. Staged updates go to <userData>/addons-updates-staged/<id>-<version>/. Promotion into <userData>/addons/<id>/ reuses seedBundledAddons's backup dance: existing folder moves to <userData>/addons-backups/<id>-<oldver>-<timestamp>/ so any local edits survive. New files: - addon-updater.js — client - addon-update-pubkeys.js — hardcoded pubkeys (empty; edit + rebuild to rotate) - scripts/generate-update-keypair.mjs — one-time keygen - scripts/sign-addon-update.mjs — operator packager+signer - docs/ADDON-UPDATES.md — operator brief + threat model Wired into main.js at boot; screenshot add-on's addon.json advertises the reference updateURL for when the endpoint goes live.
2026-09-07 21:58:30 +02:00
log(`updates: ${id}@${best.version} signature INVALID, skipping`);
return { status: "signature-invalid", newVer: best.version };
feat(theseus/addons): signed add-on update endpoint, à la Firefox XPI Decouples bundled-add-on updates from Theseus releases. An add-on whose addon.json declares an updateURL can be republished at any time without shipping a new Theseus installer; existing installs pick it up on the next boot's +30 s background check. Client flow (main-process only, no UI touchpoints in this commit): initAddons() ├── promoteStagedUpdates() # promote signed stage if newer ├── seedBundledAddons() # bundle wins over on-disk if newer └── AddonHost.discoverAndActivate() 30 s later: └── checkAndStageUpdates() # fetch, verify, download, stage Signature: Ed25519 over "silentmode.addon-update-v1|<id>|<version>|<tarball-sha256>", verified against a hardcoded set of operator pubkeys living in addon-update-pubkeys.js. Domain-separated so the operator key can't be tricked into signing a message with a different purpose. Empty pubkey array is the shipping default — checkAndStageUpdates() then short-circuits and no outbound requests are made, which is the safe posture until the operator ceremonies a key in. Payload: gzipped tar, extracted with the system tar (present on Win10 1803+, macOS, Linux). Path traversal defended by tar's default refusal of `..` entries; the extracted manifest's id + version are re-checked against the signed values before staging. Staged updates go to <userData>/addons-updates-staged/<id>-<version>/. Promotion into <userData>/addons/<id>/ reuses seedBundledAddons's backup dance: existing folder moves to <userData>/addons-backups/<id>-<oldver>-<timestamp>/ so any local edits survive. New files: - addon-updater.js — client - addon-update-pubkeys.js — hardcoded pubkeys (empty; edit + rebuild to rotate) - scripts/generate-update-keypair.mjs — one-time keygen - scripts/sign-addon-update.mjs — operator packager+signer - docs/ADDON-UPDATES.md — operator brief + threat model Wired into main.js at boot; screenshot add-on's addon.json advertises the reference updateURL for when the endpoint goes live.
2026-09-07 21:58:30 +02:00
}
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).
2026-09-20 15:26:30 +02:00
return { status: "ok", best };
}
feat(theseus/addons): signed add-on update endpoint, à la Firefox XPI Decouples bundled-add-on updates from Theseus releases. An add-on whose addon.json declares an updateURL can be republished at any time without shipping a new Theseus installer; existing installs pick it up on the next boot's +30 s background check. Client flow (main-process only, no UI touchpoints in this commit): initAddons() ├── promoteStagedUpdates() # promote signed stage if newer ├── seedBundledAddons() # bundle wins over on-disk if newer └── AddonHost.discoverAndActivate() 30 s later: └── checkAndStageUpdates() # fetch, verify, download, stage Signature: Ed25519 over "silentmode.addon-update-v1|<id>|<version>|<tarball-sha256>", verified against a hardcoded set of operator pubkeys living in addon-update-pubkeys.js. Domain-separated so the operator key can't be tricked into signing a message with a different purpose. Empty pubkey array is the shipping default — checkAndStageUpdates() then short-circuits and no outbound requests are made, which is the safe posture until the operator ceremonies a key in. Payload: gzipped tar, extracted with the system tar (present on Win10 1803+, macOS, Linux). Path traversal defended by tar's default refusal of `..` entries; the extracted manifest's id + version are re-checked against the signed values before staging. Staged updates go to <userData>/addons-updates-staged/<id>-<version>/. Promotion into <userData>/addons/<id>/ reuses seedBundledAddons's backup dance: existing folder moves to <userData>/addons-backups/<id>-<oldver>-<timestamp>/ so any local edits survive. New files: - addon-updater.js — client - addon-update-pubkeys.js — hardcoded pubkeys (empty; edit + rebuild to rotate) - scripts/generate-update-keypair.mjs — one-time keygen - scripts/sign-addon-update.mjs — operator packager+signer - docs/ADDON-UPDATES.md — operator brief + threat model Wired into main.js at boot; screenshot add-on's addon.json advertises the reference updateURL for when the endpoint goes live.
2026-09-07 21:58:30 +02:00
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).
2026-09-20 15:26:30 +02:00
// Download a package, check its sha256 against the signed value, and extract
// it to a temp dir whose addon.json must match id + version. Returns
// { ok, tmpDir, tmpFile } or { status, detail }. The caller moves tmpDir.
async function fetchVerifiedPackage({ id, version, url, sha256, log }) {
feat(theseus/addons): signed add-on update endpoint, à la Firefox XPI Decouples bundled-add-on updates from Theseus releases. An add-on whose addon.json declares an updateURL can be republished at any time without shipping a new Theseus installer; existing installs pick it up on the next boot's +30 s background check. Client flow (main-process only, no UI touchpoints in this commit): initAddons() ├── promoteStagedUpdates() # promote signed stage if newer ├── seedBundledAddons() # bundle wins over on-disk if newer └── AddonHost.discoverAndActivate() 30 s later: └── checkAndStageUpdates() # fetch, verify, download, stage Signature: Ed25519 over "silentmode.addon-update-v1|<id>|<version>|<tarball-sha256>", verified against a hardcoded set of operator pubkeys living in addon-update-pubkeys.js. Domain-separated so the operator key can't be tricked into signing a message with a different purpose. Empty pubkey array is the shipping default — checkAndStageUpdates() then short-circuits and no outbound requests are made, which is the safe posture until the operator ceremonies a key in. Payload: gzipped tar, extracted with the system tar (present on Win10 1803+, macOS, Linux). Path traversal defended by tar's default refusal of `..` entries; the extracted manifest's id + version are re-checked against the signed values before staging. Staged updates go to <userData>/addons-updates-staged/<id>-<version>/. Promotion into <userData>/addons/<id>/ reuses seedBundledAddons's backup dance: existing folder moves to <userData>/addons-backups/<id>-<oldver>-<timestamp>/ so any local edits survive. New files: - addon-updater.js — client - addon-update-pubkeys.js — hardcoded pubkeys (empty; edit + rebuild to rotate) - scripts/generate-update-keypair.mjs — one-time keygen - scripts/sign-addon-update.mjs — operator packager+signer - docs/ADDON-UPDATES.md — operator brief + threat model Wired into main.js at boot; screenshot add-on's addon.json advertises the reference updateURL for when the endpoint goes live.
2026-09-07 21:58:30 +02:00
let tarball;
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).
2026-09-20 15:26:30 +02:00
try { tarball = await httpGet(url, { timeoutMs: 60000, maxBytes: MAX_TARBALL_BYTES }); }
catch (e) { log(`updates: tarball ${id}@${version} fetch failed:`, e.message); return { status: "fetch-failed", newVer: version, detail: "tarball: " + e.message }; }
feat(theseus/addons): signed add-on update endpoint, à la Firefox XPI Decouples bundled-add-on updates from Theseus releases. An add-on whose addon.json declares an updateURL can be republished at any time without shipping a new Theseus installer; existing installs pick it up on the next boot's +30 s background check. Client flow (main-process only, no UI touchpoints in this commit): initAddons() ├── promoteStagedUpdates() # promote signed stage if newer ├── seedBundledAddons() # bundle wins over on-disk if newer └── AddonHost.discoverAndActivate() 30 s later: └── checkAndStageUpdates() # fetch, verify, download, stage Signature: Ed25519 over "silentmode.addon-update-v1|<id>|<version>|<tarball-sha256>", verified against a hardcoded set of operator pubkeys living in addon-update-pubkeys.js. Domain-separated so the operator key can't be tricked into signing a message with a different purpose. Empty pubkey array is the shipping default — checkAndStageUpdates() then short-circuits and no outbound requests are made, which is the safe posture until the operator ceremonies a key in. Payload: gzipped tar, extracted with the system tar (present on Win10 1803+, macOS, Linux). Path traversal defended by tar's default refusal of `..` entries; the extracted manifest's id + version are re-checked against the signed values before staging. Staged updates go to <userData>/addons-updates-staged/<id>-<version>/. Promotion into <userData>/addons/<id>/ reuses seedBundledAddons's backup dance: existing folder moves to <userData>/addons-backups/<id>-<oldver>-<timestamp>/ so any local edits survive. New files: - addon-updater.js — client - addon-update-pubkeys.js — hardcoded pubkeys (empty; edit + rebuild to rotate) - scripts/generate-update-keypair.mjs — one-time keygen - scripts/sign-addon-update.mjs — operator packager+signer - docs/ADDON-UPDATES.md — operator brief + threat model Wired into main.js at boot; screenshot add-on's addon.json advertises the reference updateURL for when the endpoint goes live.
2026-09-07 21:58:30 +02:00
const gotHash = crypto.createHash("sha256").update(tarball).digest("hex");
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).
2026-09-20 15:26:30 +02:00
if (gotHash.toLowerCase() !== String(sha256).toLowerCase()) {
log(`updates: ${id}@${version} sha256 mismatch (${gotHash} vs ${sha256}), skipping`);
return { status: "sha256-mismatch", newVer: version };
feat(theseus/addons): signed add-on update endpoint, à la Firefox XPI Decouples bundled-add-on updates from Theseus releases. An add-on whose addon.json declares an updateURL can be republished at any time without shipping a new Theseus installer; existing installs pick it up on the next boot's +30 s background check. Client flow (main-process only, no UI touchpoints in this commit): initAddons() ├── promoteStagedUpdates() # promote signed stage if newer ├── seedBundledAddons() # bundle wins over on-disk if newer └── AddonHost.discoverAndActivate() 30 s later: └── checkAndStageUpdates() # fetch, verify, download, stage Signature: Ed25519 over "silentmode.addon-update-v1|<id>|<version>|<tarball-sha256>", verified against a hardcoded set of operator pubkeys living in addon-update-pubkeys.js. Domain-separated so the operator key can't be tricked into signing a message with a different purpose. Empty pubkey array is the shipping default — checkAndStageUpdates() then short-circuits and no outbound requests are made, which is the safe posture until the operator ceremonies a key in. Payload: gzipped tar, extracted with the system tar (present on Win10 1803+, macOS, Linux). Path traversal defended by tar's default refusal of `..` entries; the extracted manifest's id + version are re-checked against the signed values before staging. Staged updates go to <userData>/addons-updates-staged/<id>-<version>/. Promotion into <userData>/addons/<id>/ reuses seedBundledAddons's backup dance: existing folder moves to <userData>/addons-backups/<id>-<oldver>-<timestamp>/ so any local edits survive. New files: - addon-updater.js — client - addon-update-pubkeys.js — hardcoded pubkeys (empty; edit + rebuild to rotate) - scripts/generate-update-keypair.mjs — one-time keygen - scripts/sign-addon-update.mjs — operator packager+signer - docs/ADDON-UPDATES.md — operator brief + threat model Wired into main.js at boot; screenshot add-on's addon.json advertises the reference updateURL for when the endpoint goes live.
2026-09-07 21:58:30 +02:00
}
const tag = `${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
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).
2026-09-20 15:26:30 +02:00
const tmpFile = path.join(os.tmpdir(), `sm-addon-${id}-${version}-${tag}.tgz`);
const tmpDir = path.join(os.tmpdir(), `sm-addon-${id}-${version}-${tag}.dir`);
feat(theseus/addons): signed add-on update endpoint, à la Firefox XPI Decouples bundled-add-on updates from Theseus releases. An add-on whose addon.json declares an updateURL can be republished at any time without shipping a new Theseus installer; existing installs pick it up on the next boot's +30 s background check. Client flow (main-process only, no UI touchpoints in this commit): initAddons() ├── promoteStagedUpdates() # promote signed stage if newer ├── seedBundledAddons() # bundle wins over on-disk if newer └── AddonHost.discoverAndActivate() 30 s later: └── checkAndStageUpdates() # fetch, verify, download, stage Signature: Ed25519 over "silentmode.addon-update-v1|<id>|<version>|<tarball-sha256>", verified against a hardcoded set of operator pubkeys living in addon-update-pubkeys.js. Domain-separated so the operator key can't be tricked into signing a message with a different purpose. Empty pubkey array is the shipping default — checkAndStageUpdates() then short-circuits and no outbound requests are made, which is the safe posture until the operator ceremonies a key in. Payload: gzipped tar, extracted with the system tar (present on Win10 1803+, macOS, Linux). Path traversal defended by tar's default refusal of `..` entries; the extracted manifest's id + version are re-checked against the signed values before staging. Staged updates go to <userData>/addons-updates-staged/<id>-<version>/. Promotion into <userData>/addons/<id>/ reuses seedBundledAddons's backup dance: existing folder moves to <userData>/addons-backups/<id>-<oldver>-<timestamp>/ so any local edits survive. New files: - addon-updater.js — client - addon-update-pubkeys.js — hardcoded pubkeys (empty; edit + rebuild to rotate) - scripts/generate-update-keypair.mjs — one-time keygen - scripts/sign-addon-update.mjs — operator packager+signer - docs/ADDON-UPDATES.md — operator brief + threat model Wired into main.js at boot; screenshot add-on's addon.json advertises the reference updateURL for when the endpoint goes live.
2026-09-07 21:58:30 +02:00
try {
await fsp.writeFile(tmpFile, tarball);
await fsp.mkdir(tmpDir, { recursive: true });
// Refuses `..` entries by default in modern tar. -P NOT passed = paths
// stay stripped/relative.
// Two Windows tar quirks we sidestep with one small trick:
// 1. Git-Bash tar (MSYS2) sees drive letters as host:file remote syntax
// without --force-local.
// 2. Even with --force-local, MSYS2's argument-conversion layer mangles
// backslashes it doesn't understand, so `C:\Users\...\Temp\dir`
// arrives at tar as `C:\\Users...\\dir` and it can't open the path.
// Forward-slash paths dodge both — bsdtar (Win10 built-in), GNU tar,
// and MSYS2 tar all accept `C:/Users/...` as a plain path.
// BUT: Windows 10's built-in bsdtar does NOT recognise --force-local at
// all and errors out with "unknown option". Try WITHOUT the flag first
// (safe with posix paths on every tar we care about) and fall back to
// WITH it for MSYS2 tar which parses `C:/…` as a host prefix. Either
// path is a single tar invocation — the fallback only fires on the
// exit-code failure of the first.
const posix = (p) => p.replace(/\\/g, "/");
const baseArgs = ["-x", "-z", "-f", posix(tmpFile), "-C", posix(tmpDir)];
try {
execFileSync("tar", baseArgs, { stdio: "ignore" });
} catch (e1) {
try {
execFileSync("tar", ["--force-local", ...baseArgs], { stdio: "ignore" });
} catch (e2) {
// Surface the first error; --force-local retry is opportunistic.
throw e1;
}
}
feat(theseus/addons): signed add-on update endpoint, à la Firefox XPI Decouples bundled-add-on updates from Theseus releases. An add-on whose addon.json declares an updateURL can be republished at any time without shipping a new Theseus installer; existing installs pick it up on the next boot's +30 s background check. Client flow (main-process only, no UI touchpoints in this commit): initAddons() ├── promoteStagedUpdates() # promote signed stage if newer ├── seedBundledAddons() # bundle wins over on-disk if newer └── AddonHost.discoverAndActivate() 30 s later: └── checkAndStageUpdates() # fetch, verify, download, stage Signature: Ed25519 over "silentmode.addon-update-v1|<id>|<version>|<tarball-sha256>", verified against a hardcoded set of operator pubkeys living in addon-update-pubkeys.js. Domain-separated so the operator key can't be tricked into signing a message with a different purpose. Empty pubkey array is the shipping default — checkAndStageUpdates() then short-circuits and no outbound requests are made, which is the safe posture until the operator ceremonies a key in. Payload: gzipped tar, extracted with the system tar (present on Win10 1803+, macOS, Linux). Path traversal defended by tar's default refusal of `..` entries; the extracted manifest's id + version are re-checked against the signed values before staging. Staged updates go to <userData>/addons-updates-staged/<id>-<version>/. Promotion into <userData>/addons/<id>/ reuses seedBundledAddons's backup dance: existing folder moves to <userData>/addons-backups/<id>-<oldver>-<timestamp>/ so any local edits survive. New files: - addon-updater.js — client - addon-update-pubkeys.js — hardcoded pubkeys (empty; edit + rebuild to rotate) - scripts/generate-update-keypair.mjs — one-time keygen - scripts/sign-addon-update.mjs — operator packager+signer - docs/ADDON-UPDATES.md — operator brief + threat model Wired into main.js at boot; screenshot add-on's addon.json advertises the reference updateURL for when the endpoint goes live.
2026-09-07 21:58:30 +02:00
} catch (e) {
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).
2026-09-20 15:26:30 +02:00
log(`updates: extract ${id}@${version} failed:`, e.message);
feat(theseus/addons): signed add-on update endpoint, à la Firefox XPI Decouples bundled-add-on updates from Theseus releases. An add-on whose addon.json declares an updateURL can be republished at any time without shipping a new Theseus installer; existing installs pick it up on the next boot's +30 s background check. Client flow (main-process only, no UI touchpoints in this commit): initAddons() ├── promoteStagedUpdates() # promote signed stage if newer ├── seedBundledAddons() # bundle wins over on-disk if newer └── AddonHost.discoverAndActivate() 30 s later: └── checkAndStageUpdates() # fetch, verify, download, stage Signature: Ed25519 over "silentmode.addon-update-v1|<id>|<version>|<tarball-sha256>", verified against a hardcoded set of operator pubkeys living in addon-update-pubkeys.js. Domain-separated so the operator key can't be tricked into signing a message with a different purpose. Empty pubkey array is the shipping default — checkAndStageUpdates() then short-circuits and no outbound requests are made, which is the safe posture until the operator ceremonies a key in. Payload: gzipped tar, extracted with the system tar (present on Win10 1803+, macOS, Linux). Path traversal defended by tar's default refusal of `..` entries; the extracted manifest's id + version are re-checked against the signed values before staging. Staged updates go to <userData>/addons-updates-staged/<id>-<version>/. Promotion into <userData>/addons/<id>/ reuses seedBundledAddons's backup dance: existing folder moves to <userData>/addons-backups/<id>-<oldver>-<timestamp>/ so any local edits survive. New files: - addon-updater.js — client - addon-update-pubkeys.js — hardcoded pubkeys (empty; edit + rebuild to rotate) - scripts/generate-update-keypair.mjs — one-time keygen - scripts/sign-addon-update.mjs — operator packager+signer - docs/ADDON-UPDATES.md — operator brief + threat model Wired into main.js at boot; screenshot add-on's addon.json advertises the reference updateURL for when the endpoint goes live.
2026-09-07 21:58:30 +02:00
try { fs.rmSync(tmpFile, { force: true }); } catch {}
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
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).
2026-09-20 15:26:30 +02:00
return { status: "extract-failed", newVer: version, detail: e.message };
feat(theseus/addons): signed add-on update endpoint, à la Firefox XPI Decouples bundled-add-on updates from Theseus releases. An add-on whose addon.json declares an updateURL can be republished at any time without shipping a new Theseus installer; existing installs pick it up on the next boot's +30 s background check. Client flow (main-process only, no UI touchpoints in this commit): initAddons() ├── promoteStagedUpdates() # promote signed stage if newer ├── seedBundledAddons() # bundle wins over on-disk if newer └── AddonHost.discoverAndActivate() 30 s later: └── checkAndStageUpdates() # fetch, verify, download, stage Signature: Ed25519 over "silentmode.addon-update-v1|<id>|<version>|<tarball-sha256>", verified against a hardcoded set of operator pubkeys living in addon-update-pubkeys.js. Domain-separated so the operator key can't be tricked into signing a message with a different purpose. Empty pubkey array is the shipping default — checkAndStageUpdates() then short-circuits and no outbound requests are made, which is the safe posture until the operator ceremonies a key in. Payload: gzipped tar, extracted with the system tar (present on Win10 1803+, macOS, Linux). Path traversal defended by tar's default refusal of `..` entries; the extracted manifest's id + version are re-checked against the signed values before staging. Staged updates go to <userData>/addons-updates-staged/<id>-<version>/. Promotion into <userData>/addons/<id>/ reuses seedBundledAddons's backup dance: existing folder moves to <userData>/addons-backups/<id>-<oldver>-<timestamp>/ so any local edits survive. New files: - addon-updater.js — client - addon-update-pubkeys.js — hardcoded pubkeys (empty; edit + rebuild to rotate) - scripts/generate-update-keypair.mjs — one-time keygen - scripts/sign-addon-update.mjs — operator packager+signer - docs/ADDON-UPDATES.md — operator brief + threat model Wired into main.js at boot; screenshot add-on's addon.json advertises the reference updateURL for when the endpoint goes live.
2026-09-07 21:58:30 +02:00
}
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).
2026-09-20 15:26:30 +02:00
// A package may wrap everything in one top-level folder (tar -czf x.tgz my-ext);
// unwrap it so addon.json sits at the root like the bundled add-ons.
let root = tmpDir;
if (!readAddonJson(root)) {
const kids = fs.readdirSync(root, { withFileTypes: true }).filter((d) => !d.name.startsWith("."));
if (kids.length === 1 && kids[0].isDirectory() && readAddonJson(path.join(root, kids[0].name))) root = path.join(root, kids[0].name);
}
const extracted = readAddonJson(root);
if (!extracted || extracted.id !== id || extracted.version !== version) {
log(`updates: extracted ${id}@${version} manifest mismatch (got ${extracted?.id}@${extracted?.version}), dropping`);
feat(theseus/addons): signed add-on update endpoint, à la Firefox XPI Decouples bundled-add-on updates from Theseus releases. An add-on whose addon.json declares an updateURL can be republished at any time without shipping a new Theseus installer; existing installs pick it up on the next boot's +30 s background check. Client flow (main-process only, no UI touchpoints in this commit): initAddons() ├── promoteStagedUpdates() # promote signed stage if newer ├── seedBundledAddons() # bundle wins over on-disk if newer └── AddonHost.discoverAndActivate() 30 s later: └── checkAndStageUpdates() # fetch, verify, download, stage Signature: Ed25519 over "silentmode.addon-update-v1|<id>|<version>|<tarball-sha256>", verified against a hardcoded set of operator pubkeys living in addon-update-pubkeys.js. Domain-separated so the operator key can't be tricked into signing a message with a different purpose. Empty pubkey array is the shipping default — checkAndStageUpdates() then short-circuits and no outbound requests are made, which is the safe posture until the operator ceremonies a key in. Payload: gzipped tar, extracted with the system tar (present on Win10 1803+, macOS, Linux). Path traversal defended by tar's default refusal of `..` entries; the extracted manifest's id + version are re-checked against the signed values before staging. Staged updates go to <userData>/addons-updates-staged/<id>-<version>/. Promotion into <userData>/addons/<id>/ reuses seedBundledAddons's backup dance: existing folder moves to <userData>/addons-backups/<id>-<oldver>-<timestamp>/ so any local edits survive. New files: - addon-updater.js — client - addon-update-pubkeys.js — hardcoded pubkeys (empty; edit + rebuild to rotate) - scripts/generate-update-keypair.mjs — one-time keygen - scripts/sign-addon-update.mjs — operator packager+signer - docs/ADDON-UPDATES.md — operator brief + threat model Wired into main.js at boot; screenshot add-on's addon.json advertises the reference updateURL for when the endpoint goes live.
2026-09-07 21:58:30 +02:00
try { fs.rmSync(tmpFile, { force: true }); } catch {}
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
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).
2026-09-20 15:26:30 +02:00
return { status: "manifest-mismatch", newVer: version, detail: `got ${extracted?.id}@${extracted?.version}` };
feat(theseus/addons): signed add-on update endpoint, à la Firefox XPI Decouples bundled-add-on updates from Theseus releases. An add-on whose addon.json declares an updateURL can be republished at any time without shipping a new Theseus installer; existing installs pick it up on the next boot's +30 s background check. Client flow (main-process only, no UI touchpoints in this commit): initAddons() ├── promoteStagedUpdates() # promote signed stage if newer ├── seedBundledAddons() # bundle wins over on-disk if newer └── AddonHost.discoverAndActivate() 30 s later: └── checkAndStageUpdates() # fetch, verify, download, stage Signature: Ed25519 over "silentmode.addon-update-v1|<id>|<version>|<tarball-sha256>", verified against a hardcoded set of operator pubkeys living in addon-update-pubkeys.js. Domain-separated so the operator key can't be tricked into signing a message with a different purpose. Empty pubkey array is the shipping default — checkAndStageUpdates() then short-circuits and no outbound requests are made, which is the safe posture until the operator ceremonies a key in. Payload: gzipped tar, extracted with the system tar (present on Win10 1803+, macOS, Linux). Path traversal defended by tar's default refusal of `..` entries; the extracted manifest's id + version are re-checked against the signed values before staging. Staged updates go to <userData>/addons-updates-staged/<id>-<version>/. Promotion into <userData>/addons/<id>/ reuses seedBundledAddons's backup dance: existing folder moves to <userData>/addons-backups/<id>-<oldver>-<timestamp>/ so any local edits survive. New files: - addon-updater.js — client - addon-update-pubkeys.js — hardcoded pubkeys (empty; edit + rebuild to rotate) - scripts/generate-update-keypair.mjs — one-time keygen - scripts/sign-addon-update.mjs — operator packager+signer - docs/ADDON-UPDATES.md — operator brief + threat model Wired into main.js at boot; screenshot add-on's addon.json advertises the reference updateURL for when the endpoint goes live.
2026-09-07 21:58:30 +02:00
}
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).
2026-09-20 15:26:30 +02:00
return { ok: true, tmpDir: root, tmpTop: tmpDir, tmpFile, manifest: extracted };
}
// Move an extracted package into place (rename, with copy fallback across volumes).
function placeDir(from, to) {
try { fs.renameSync(from, to); }
catch { fs.cpSync(from, to, { recursive: true }); fs.rmSync(from, { recursive: true, force: true }); }
}
async function stageOne({ id, currentVer, updateURL, stagedDir, pubkeysHex, verifyPublisher, log, timeoutMs }) {
const picked = await pickChannelEntry({ id, currentVer, updateURL, pubkeysHex, verifyPublisher, log, timeoutMs });
if (picked.status !== "ok") return picked;
const best = picked.best;
const stageOut = path.join(stagedDir, `${id}-${best.version}`);
const stagedVer = readAddonJson(stageOut)?.version;
if (stagedVer === best.version) { log(`updates: ${id}@${best.version} already staged`); return { status: "already-staged", newVer: best.version, stagePath: stageOut }; }
if (fs.existsSync(stageOut)) { try { fs.rmSync(stageOut, { recursive: true, force: true }); } catch {} }
const pkg = await fetchVerifiedPackage({ id, version: best.version, url: best.url, sha256: best.sha256, log });
if (!pkg.ok) return pkg;
feat(theseus/addons): signed add-on update endpoint, à la Firefox XPI Decouples bundled-add-on updates from Theseus releases. An add-on whose addon.json declares an updateURL can be republished at any time without shipping a new Theseus installer; existing installs pick it up on the next boot's +30 s background check. Client flow (main-process only, no UI touchpoints in this commit): initAddons() ├── promoteStagedUpdates() # promote signed stage if newer ├── seedBundledAddons() # bundle wins over on-disk if newer └── AddonHost.discoverAndActivate() 30 s later: └── checkAndStageUpdates() # fetch, verify, download, stage Signature: Ed25519 over "silentmode.addon-update-v1|<id>|<version>|<tarball-sha256>", verified against a hardcoded set of operator pubkeys living in addon-update-pubkeys.js. Domain-separated so the operator key can't be tricked into signing a message with a different purpose. Empty pubkey array is the shipping default — checkAndStageUpdates() then short-circuits and no outbound requests are made, which is the safe posture until the operator ceremonies a key in. Payload: gzipped tar, extracted with the system tar (present on Win10 1803+, macOS, Linux). Path traversal defended by tar's default refusal of `..` entries; the extracted manifest's id + version are re-checked against the signed values before staging. Staged updates go to <userData>/addons-updates-staged/<id>-<version>/. Promotion into <userData>/addons/<id>/ reuses seedBundledAddons's backup dance: existing folder moves to <userData>/addons-backups/<id>-<oldver>-<timestamp>/ so any local edits survive. New files: - addon-updater.js — client - addon-update-pubkeys.js — hardcoded pubkeys (empty; edit + rebuild to rotate) - scripts/generate-update-keypair.mjs — one-time keygen - scripts/sign-addon-update.mjs — operator packager+signer - docs/ADDON-UPDATES.md — operator brief + threat model Wired into main.js at boot; screenshot add-on's addon.json advertises the reference updateURL for when the endpoint goes live.
2026-09-07 21:58:30 +02:00
try { fs.mkdirSync(stagedDir, { recursive: true }); } catch {}
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).
2026-09-20 15:26:30 +02:00
try { placeDir(pkg.tmpDir, stageOut); }
catch (ee) { log(`updates: stage move ${id}@${best.version} failed:`, ee.message); return { status: "extract-failed", newVer: best.version, detail: "stage move: " + ee.message }; }
try { fs.rmSync(pkg.tmpTop, { recursive: true, force: true }); } catch {}
try { fs.rmSync(pkg.tmpFile, { force: true }); } catch {}
feat(theseus/addons): signed add-on update endpoint, à la Firefox XPI Decouples bundled-add-on updates from Theseus releases. An add-on whose addon.json declares an updateURL can be republished at any time without shipping a new Theseus installer; existing installs pick it up on the next boot's +30 s background check. Client flow (main-process only, no UI touchpoints in this commit): initAddons() ├── promoteStagedUpdates() # promote signed stage if newer ├── seedBundledAddons() # bundle wins over on-disk if newer └── AddonHost.discoverAndActivate() 30 s later: └── checkAndStageUpdates() # fetch, verify, download, stage Signature: Ed25519 over "silentmode.addon-update-v1|<id>|<version>|<tarball-sha256>", verified against a hardcoded set of operator pubkeys living in addon-update-pubkeys.js. Domain-separated so the operator key can't be tricked into signing a message with a different purpose. Empty pubkey array is the shipping default — checkAndStageUpdates() then short-circuits and no outbound requests are made, which is the safe posture until the operator ceremonies a key in. Payload: gzipped tar, extracted with the system tar (present on Win10 1803+, macOS, Linux). Path traversal defended by tar's default refusal of `..` entries; the extracted manifest's id + version are re-checked against the signed values before staging. Staged updates go to <userData>/addons-updates-staged/<id>-<version>/. Promotion into <userData>/addons/<id>/ reuses seedBundledAddons's backup dance: existing folder moves to <userData>/addons-backups/<id>-<oldver>-<timestamp>/ so any local edits survive. New files: - addon-updater.js — client - addon-update-pubkeys.js — hardcoded pubkeys (empty; edit + rebuild to rotate) - scripts/generate-update-keypair.mjs — one-time keygen - scripts/sign-addon-update.mjs — operator packager+signer - docs/ADDON-UPDATES.md — operator brief + threat model Wired into main.js at boot; screenshot add-on's addon.json advertises the reference updateURL for when the endpoint goes live.
2026-09-07 21:58:30 +02:00
log(`updates: staged ${id}@${best.version} — will apply on next launch`);
return { status: "staged", newVer: best.version, stagePath: stageOut };
feat(theseus/addons): signed add-on update endpoint, à la Firefox XPI Decouples bundled-add-on updates from Theseus releases. An add-on whose addon.json declares an updateURL can be republished at any time without shipping a new Theseus installer; existing installs pick it up on the next boot's +30 s background check. Client flow (main-process only, no UI touchpoints in this commit): initAddons() ├── promoteStagedUpdates() # promote signed stage if newer ├── seedBundledAddons() # bundle wins over on-disk if newer └── AddonHost.discoverAndActivate() 30 s later: └── checkAndStageUpdates() # fetch, verify, download, stage Signature: Ed25519 over "silentmode.addon-update-v1|<id>|<version>|<tarball-sha256>", verified against a hardcoded set of operator pubkeys living in addon-update-pubkeys.js. Domain-separated so the operator key can't be tricked into signing a message with a different purpose. Empty pubkey array is the shipping default — checkAndStageUpdates() then short-circuits and no outbound requests are made, which is the safe posture until the operator ceremonies a key in. Payload: gzipped tar, extracted with the system tar (present on Win10 1803+, macOS, Linux). Path traversal defended by tar's default refusal of `..` entries; the extracted manifest's id + version are re-checked against the signed values before staging. Staged updates go to <userData>/addons-updates-staged/<id>-<version>/. Promotion into <userData>/addons/<id>/ reuses seedBundledAddons's backup dance: existing folder moves to <userData>/addons-backups/<id>-<oldver>-<timestamp>/ so any local edits survive. New files: - addon-updater.js — client - addon-update-pubkeys.js — hardcoded pubkeys (empty; edit + rebuild to rotate) - scripts/generate-update-keypair.mjs — one-time keygen - scripts/sign-addon-update.mjs — operator packager+signer - docs/ADDON-UPDATES.md — operator brief + threat model Wired into main.js at boot; screenshot add-on's addon.json advertises the reference updateURL for when the endpoint goes live.
2026-09-07 21:58:30 +02:00
}
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).
2026-09-20 15:26:30 +02:00
// ---------- community install (theseus.x/extensions) ---------------------
// Install or update one community extension straight into addonsDir from
// its channel manifest. Trust is the publisher's signature (verifyPublisher
// checks it against the name's NFT owner); a prior copy is kept in backupsDir
// like every other add-on swap. The installed addon.json gets `updateURL`
// and `publisher` so the regular update check covers it from then on.
async function installCommunity({ id, updatesUrl, addonsDir, backupsDir, verifyPublisher, log = () => {}, timeoutMs = 15000 }) {
if (typeof verifyPublisher !== "function") return { ok: false, error: "no publisher verifier" };
const dest = path.join(addonsDir, id);
const currentVer = readAddonJson(dest)?.version || null;
const picked = await pickChannelEntry({ id, currentVer, updateURL: updatesUrl, pubkeysHex: [], verifyPublisher, log, timeoutMs });
if (picked.status === "up-to-date") return { ok: false, error: currentVer ? `already installed (v${currentVer})` : "channel has no installable version" };
if (picked.status !== "ok") return { ok: false, error: picked.status + (picked.detail ? ": " + picked.detail : ""), status: picked.status };
const best = picked.best;
const pkg = await fetchVerifiedPackage({ id, version: best.version, url: best.url, sha256: best.sha256, log });
if (!pkg.ok) return { ok: false, error: pkg.status + (pkg.detail ? ": " + pkg.detail : ""), status: pkg.status };
try {
// Record where updates come from and who signed this copy.
const mf = { ...pkg.manifest };
if (!mf.updateURL) mf.updateURL = updatesUrl;
mf.publisher = best.publisher;
fs.writeFileSync(path.join(pkg.tmpDir, "addon.json"), JSON.stringify(mf, null, 2));
fs.mkdirSync(addonsDir, { recursive: true });
if (fs.existsSync(dest)) {
fs.mkdirSync(backupsDir, { recursive: true });
const backup = path.join(backupsDir, `${id}-${currentVer || "unknown"}-${Date.now()}`);
placeDir(dest, backup);
log(`community: previous ${id} moved to ${backup}`);
}
placeDir(pkg.tmpDir, dest);
} catch (e) {
try { fs.rmSync(pkg.tmpTop, { recursive: true, force: true }); } catch {}
return { ok: false, error: "install: " + e.message };
} finally {
try { fs.rmSync(pkg.tmpTop, { recursive: true, force: true }); } catch {}
try { fs.rmSync(pkg.tmpFile, { force: true }); } catch {}
}
log(`community: installed ${id}@${best.version} signed by ${best.publisher}`);
return { ok: true, id, version: best.version, publisher: best.publisher, previous: currentVer };
}
// Returns a `report` array: one entry per installed add-on the client
// considered — { id, currentVer, updateURL, status, detail? }. Status is
// one of: "no-update-url" | "fetch-failed" | "up-to-date" | "signature-invalid"
// | "sha256-mismatch" | "extract-failed" | "manifest-mismatch"
// | "staged" | "already-staged". The manual UI in Settings > Extensions
// uses this to tell the user WHY nothing landed instead of a single
// "up to date" that hides fetch/verify failures.
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).
2026-09-20 15:26:30 +02:00
async function checkAndStageUpdates({ addonsDir, stagedDir, pubkeysHex, verifyPublisher, timeoutMs = 15000, logger }) {
feat(theseus/addons): signed add-on update endpoint, à la Firefox XPI Decouples bundled-add-on updates from Theseus releases. An add-on whose addon.json declares an updateURL can be republished at any time without shipping a new Theseus installer; existing installs pick it up on the next boot's +30 s background check. Client flow (main-process only, no UI touchpoints in this commit): initAddons() ├── promoteStagedUpdates() # promote signed stage if newer ├── seedBundledAddons() # bundle wins over on-disk if newer └── AddonHost.discoverAndActivate() 30 s later: └── checkAndStageUpdates() # fetch, verify, download, stage Signature: Ed25519 over "silentmode.addon-update-v1|<id>|<version>|<tarball-sha256>", verified against a hardcoded set of operator pubkeys living in addon-update-pubkeys.js. Domain-separated so the operator key can't be tricked into signing a message with a different purpose. Empty pubkey array is the shipping default — checkAndStageUpdates() then short-circuits and no outbound requests are made, which is the safe posture until the operator ceremonies a key in. Payload: gzipped tar, extracted with the system tar (present on Win10 1803+, macOS, Linux). Path traversal defended by tar's default refusal of `..` entries; the extracted manifest's id + version are re-checked against the signed values before staging. Staged updates go to <userData>/addons-updates-staged/<id>-<version>/. Promotion into <userData>/addons/<id>/ reuses seedBundledAddons's backup dance: existing folder moves to <userData>/addons-backups/<id>-<oldver>-<timestamp>/ so any local edits survive. New files: - addon-updater.js — client - addon-update-pubkeys.js — hardcoded pubkeys (empty; edit + rebuild to rotate) - scripts/generate-update-keypair.mjs — one-time keygen - scripts/sign-addon-update.mjs — operator packager+signer - docs/ADDON-UPDATES.md — operator brief + threat model Wired into main.js at boot; screenshot add-on's addon.json advertises the reference updateURL for when the endpoint goes live.
2026-09-07 21:58:30 +02:00
const log = logger || (() => {});
const report = [];
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).
2026-09-20 15:26:30 +02:00
if ((!Array.isArray(pubkeysHex) || pubkeysHex.length === 0) && typeof verifyPublisher !== "function") {
feat(theseus/addons): signed add-on update endpoint, à la Firefox XPI Decouples bundled-add-on updates from Theseus releases. An add-on whose addon.json declares an updateURL can be republished at any time without shipping a new Theseus installer; existing installs pick it up on the next boot's +30 s background check. Client flow (main-process only, no UI touchpoints in this commit): initAddons() ├── promoteStagedUpdates() # promote signed stage if newer ├── seedBundledAddons() # bundle wins over on-disk if newer └── AddonHost.discoverAndActivate() 30 s later: └── checkAndStageUpdates() # fetch, verify, download, stage Signature: Ed25519 over "silentmode.addon-update-v1|<id>|<version>|<tarball-sha256>", verified against a hardcoded set of operator pubkeys living in addon-update-pubkeys.js. Domain-separated so the operator key can't be tricked into signing a message with a different purpose. Empty pubkey array is the shipping default — checkAndStageUpdates() then short-circuits and no outbound requests are made, which is the safe posture until the operator ceremonies a key in. Payload: gzipped tar, extracted with the system tar (present on Win10 1803+, macOS, Linux). Path traversal defended by tar's default refusal of `..` entries; the extracted manifest's id + version are re-checked against the signed values before staging. Staged updates go to <userData>/addons-updates-staged/<id>-<version>/. Promotion into <userData>/addons/<id>/ reuses seedBundledAddons's backup dance: existing folder moves to <userData>/addons-backups/<id>-<oldver>-<timestamp>/ so any local edits survive. New files: - addon-updater.js — client - addon-update-pubkeys.js — hardcoded pubkeys (empty; edit + rebuild to rotate) - scripts/generate-update-keypair.mjs — one-time keygen - scripts/sign-addon-update.mjs — operator packager+signer - docs/ADDON-UPDATES.md — operator brief + threat model Wired into main.js at boot; screenshot add-on's addon.json advertises the reference updateURL for when the endpoint goes live.
2026-09-07 21:58:30 +02:00
log("updates: no operator pubkeys configured — skipping update check");
return { report, skipped: "no-pubkeys" };
feat(theseus/addons): signed add-on update endpoint, à la Firefox XPI Decouples bundled-add-on updates from Theseus releases. An add-on whose addon.json declares an updateURL can be republished at any time without shipping a new Theseus installer; existing installs pick it up on the next boot's +30 s background check. Client flow (main-process only, no UI touchpoints in this commit): initAddons() ├── promoteStagedUpdates() # promote signed stage if newer ├── seedBundledAddons() # bundle wins over on-disk if newer └── AddonHost.discoverAndActivate() 30 s later: └── checkAndStageUpdates() # fetch, verify, download, stage Signature: Ed25519 over "silentmode.addon-update-v1|<id>|<version>|<tarball-sha256>", verified against a hardcoded set of operator pubkeys living in addon-update-pubkeys.js. Domain-separated so the operator key can't be tricked into signing a message with a different purpose. Empty pubkey array is the shipping default — checkAndStageUpdates() then short-circuits and no outbound requests are made, which is the safe posture until the operator ceremonies a key in. Payload: gzipped tar, extracted with the system tar (present on Win10 1803+, macOS, Linux). Path traversal defended by tar's default refusal of `..` entries; the extracted manifest's id + version are re-checked against the signed values before staging. Staged updates go to <userData>/addons-updates-staged/<id>-<version>/. Promotion into <userData>/addons/<id>/ reuses seedBundledAddons's backup dance: existing folder moves to <userData>/addons-backups/<id>-<oldver>-<timestamp>/ so any local edits survive. New files: - addon-updater.js — client - addon-update-pubkeys.js — hardcoded pubkeys (empty; edit + rebuild to rotate) - scripts/generate-update-keypair.mjs — one-time keygen - scripts/sign-addon-update.mjs — operator packager+signer - docs/ADDON-UPDATES.md — operator brief + threat model Wired into main.js at boot; screenshot add-on's addon.json advertises the reference updateURL for when the endpoint goes live.
2026-09-07 21:58:30 +02:00
}
let entries;
try { entries = fs.readdirSync(addonsDir, { withFileTypes: true }); }
catch { return { report, skipped: "no-addons-dir" }; }
feat(theseus/addons): signed add-on update endpoint, à la Firefox XPI Decouples bundled-add-on updates from Theseus releases. An add-on whose addon.json declares an updateURL can be republished at any time without shipping a new Theseus installer; existing installs pick it up on the next boot's +30 s background check. Client flow (main-process only, no UI touchpoints in this commit): initAddons() ├── promoteStagedUpdates() # promote signed stage if newer ├── seedBundledAddons() # bundle wins over on-disk if newer └── AddonHost.discoverAndActivate() 30 s later: └── checkAndStageUpdates() # fetch, verify, download, stage Signature: Ed25519 over "silentmode.addon-update-v1|<id>|<version>|<tarball-sha256>", verified against a hardcoded set of operator pubkeys living in addon-update-pubkeys.js. Domain-separated so the operator key can't be tricked into signing a message with a different purpose. Empty pubkey array is the shipping default — checkAndStageUpdates() then short-circuits and no outbound requests are made, which is the safe posture until the operator ceremonies a key in. Payload: gzipped tar, extracted with the system tar (present on Win10 1803+, macOS, Linux). Path traversal defended by tar's default refusal of `..` entries; the extracted manifest's id + version are re-checked against the signed values before staging. Staged updates go to <userData>/addons-updates-staged/<id>-<version>/. Promotion into <userData>/addons/<id>/ reuses seedBundledAddons's backup dance: existing folder moves to <userData>/addons-backups/<id>-<oldver>-<timestamp>/ so any local edits survive. New files: - addon-updater.js — client - addon-update-pubkeys.js — hardcoded pubkeys (empty; edit + rebuild to rotate) - scripts/generate-update-keypair.mjs — one-time keygen - scripts/sign-addon-update.mjs — operator packager+signer - docs/ADDON-UPDATES.md — operator brief + threat model Wired into main.js at boot; screenshot add-on's addon.json advertises the reference updateURL for when the endpoint goes live.
2026-09-07 21:58:30 +02:00
const pending = [];
for (const de of entries) {
if (!de.isDirectory()) continue;
const manifest = readAddonJson(path.join(addonsDir, de.name));
if (!manifest?.id) continue;
if (!manifest?.updateURL) {
report.push({ id: manifest.id, currentVer: manifest.version, updateURL: null, status: "no-update-url" });
continue;
}
feat(theseus/addons): signed add-on update endpoint, à la Firefox XPI Decouples bundled-add-on updates from Theseus releases. An add-on whose addon.json declares an updateURL can be republished at any time without shipping a new Theseus installer; existing installs pick it up on the next boot's +30 s background check. Client flow (main-process only, no UI touchpoints in this commit): initAddons() ├── promoteStagedUpdates() # promote signed stage if newer ├── seedBundledAddons() # bundle wins over on-disk if newer └── AddonHost.discoverAndActivate() 30 s later: └── checkAndStageUpdates() # fetch, verify, download, stage Signature: Ed25519 over "silentmode.addon-update-v1|<id>|<version>|<tarball-sha256>", verified against a hardcoded set of operator pubkeys living in addon-update-pubkeys.js. Domain-separated so the operator key can't be tricked into signing a message with a different purpose. Empty pubkey array is the shipping default — checkAndStageUpdates() then short-circuits and no outbound requests are made, which is the safe posture until the operator ceremonies a key in. Payload: gzipped tar, extracted with the system tar (present on Win10 1803+, macOS, Linux). Path traversal defended by tar's default refusal of `..` entries; the extracted manifest's id + version are re-checked against the signed values before staging. Staged updates go to <userData>/addons-updates-staged/<id>-<version>/. Promotion into <userData>/addons/<id>/ reuses seedBundledAddons's backup dance: existing folder moves to <userData>/addons-backups/<id>-<oldver>-<timestamp>/ so any local edits survive. New files: - addon-updater.js — client - addon-update-pubkeys.js — hardcoded pubkeys (empty; edit + rebuild to rotate) - scripts/generate-update-keypair.mjs — one-time keygen - scripts/sign-addon-update.mjs — operator packager+signer - docs/ADDON-UPDATES.md — operator brief + threat model Wired into main.js at boot; screenshot add-on's addon.json advertises the reference updateURL for when the endpoint goes live.
2026-09-07 21:58:30 +02:00
pending.push(
stageOne({
id: manifest.id,
currentVer: manifest.version,
updateURL: manifest.updateURL,
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).
2026-09-20 15:26:30 +02:00
stagedDir, pubkeysHex, verifyPublisher, log, timeoutMs,
})
.then((r) => report.push({ id: manifest.id, currentVer: manifest.version, updateURL: manifest.updateURL, ...r }))
.catch((e) => { log(`updates: ${manifest.id} unexpected error:`, e.message); report.push({ id: manifest.id, currentVer: manifest.version, updateURL: manifest.updateURL, status: "unexpected-error", detail: e.message }); })
feat(theseus/addons): signed add-on update endpoint, à la Firefox XPI Decouples bundled-add-on updates from Theseus releases. An add-on whose addon.json declares an updateURL can be republished at any time without shipping a new Theseus installer; existing installs pick it up on the next boot's +30 s background check. Client flow (main-process only, no UI touchpoints in this commit): initAddons() ├── promoteStagedUpdates() # promote signed stage if newer ├── seedBundledAddons() # bundle wins over on-disk if newer └── AddonHost.discoverAndActivate() 30 s later: └── checkAndStageUpdates() # fetch, verify, download, stage Signature: Ed25519 over "silentmode.addon-update-v1|<id>|<version>|<tarball-sha256>", verified against a hardcoded set of operator pubkeys living in addon-update-pubkeys.js. Domain-separated so the operator key can't be tricked into signing a message with a different purpose. Empty pubkey array is the shipping default — checkAndStageUpdates() then short-circuits and no outbound requests are made, which is the safe posture until the operator ceremonies a key in. Payload: gzipped tar, extracted with the system tar (present on Win10 1803+, macOS, Linux). Path traversal defended by tar's default refusal of `..` entries; the extracted manifest's id + version are re-checked against the signed values before staging. Staged updates go to <userData>/addons-updates-staged/<id>-<version>/. Promotion into <userData>/addons/<id>/ reuses seedBundledAddons's backup dance: existing folder moves to <userData>/addons-backups/<id>-<oldver>-<timestamp>/ so any local edits survive. New files: - addon-updater.js — client - addon-update-pubkeys.js — hardcoded pubkeys (empty; edit + rebuild to rotate) - scripts/generate-update-keypair.mjs — one-time keygen - scripts/sign-addon-update.mjs — operator packager+signer - docs/ADDON-UPDATES.md — operator brief + threat model Wired into main.js at boot; screenshot add-on's addon.json advertises the reference updateURL for when the endpoint goes live.
2026-09-07 21:58:30 +02:00
);
}
await Promise.all(pending);
return { report };
feat(theseus/addons): signed add-on update endpoint, à la Firefox XPI Decouples bundled-add-on updates from Theseus releases. An add-on whose addon.json declares an updateURL can be republished at any time without shipping a new Theseus installer; existing installs pick it up on the next boot's +30 s background check. Client flow (main-process only, no UI touchpoints in this commit): initAddons() ├── promoteStagedUpdates() # promote signed stage if newer ├── seedBundledAddons() # bundle wins over on-disk if newer └── AddonHost.discoverAndActivate() 30 s later: └── checkAndStageUpdates() # fetch, verify, download, stage Signature: Ed25519 over "silentmode.addon-update-v1|<id>|<version>|<tarball-sha256>", verified against a hardcoded set of operator pubkeys living in addon-update-pubkeys.js. Domain-separated so the operator key can't be tricked into signing a message with a different purpose. Empty pubkey array is the shipping default — checkAndStageUpdates() then short-circuits and no outbound requests are made, which is the safe posture until the operator ceremonies a key in. Payload: gzipped tar, extracted with the system tar (present on Win10 1803+, macOS, Linux). Path traversal defended by tar's default refusal of `..` entries; the extracted manifest's id + version are re-checked against the signed values before staging. Staged updates go to <userData>/addons-updates-staged/<id>-<version>/. Promotion into <userData>/addons/<id>/ reuses seedBundledAddons's backup dance: existing folder moves to <userData>/addons-backups/<id>-<oldver>-<timestamp>/ so any local edits survive. New files: - addon-updater.js — client - addon-update-pubkeys.js — hardcoded pubkeys (empty; edit + rebuild to rotate) - scripts/generate-update-keypair.mjs — one-time keygen - scripts/sign-addon-update.mjs — operator packager+signer - docs/ADDON-UPDATES.md — operator brief + threat model Wired into main.js at boot; screenshot add-on's addon.json advertises the reference updateURL for when the endpoint goes live.
2026-09-07 21:58:30 +02:00
}
module.exports = {
cmpVer,
promoteStagedUpdates,
checkAndStageUpdates,
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).
2026-09-20 15:26:30 +02:00
installCommunity,
feat(theseus/addons): signed add-on update endpoint, à la Firefox XPI Decouples bundled-add-on updates from Theseus releases. An add-on whose addon.json declares an updateURL can be republished at any time without shipping a new Theseus installer; existing installs pick it up on the next boot's +30 s background check. Client flow (main-process only, no UI touchpoints in this commit): initAddons() ├── promoteStagedUpdates() # promote signed stage if newer ├── seedBundledAddons() # bundle wins over on-disk if newer └── AddonHost.discoverAndActivate() 30 s later: └── checkAndStageUpdates() # fetch, verify, download, stage Signature: Ed25519 over "silentmode.addon-update-v1|<id>|<version>|<tarball-sha256>", verified against a hardcoded set of operator pubkeys living in addon-update-pubkeys.js. Domain-separated so the operator key can't be tricked into signing a message with a different purpose. Empty pubkey array is the shipping default — checkAndStageUpdates() then short-circuits and no outbound requests are made, which is the safe posture until the operator ceremonies a key in. Payload: gzipped tar, extracted with the system tar (present on Win10 1803+, macOS, Linux). Path traversal defended by tar's default refusal of `..` entries; the extracted manifest's id + version are re-checked against the signed values before staging. Staged updates go to <userData>/addons-updates-staged/<id>-<version>/. Promotion into <userData>/addons/<id>/ reuses seedBundledAddons's backup dance: existing folder moves to <userData>/addons-backups/<id>-<oldver>-<timestamp>/ so any local edits survive. New files: - addon-updater.js — client - addon-update-pubkeys.js — hardcoded pubkeys (empty; edit + rebuild to rotate) - scripts/generate-update-keypair.mjs — one-time keygen - scripts/sign-addon-update.mjs — operator packager+signer - docs/ADDON-UPDATES.md — operator brief + threat model Wired into main.js at boot; screenshot add-on's addon.json advertises the reference updateURL for when the endpoint goes live.
2026-09-07 21:58:30 +02:00
verifySignature,
SIG_DOMAIN,
};