theseus/addon-updater.js
Local Dev da0bad5307 Theseus: no links in a sandboxed extension's folder
A community extension runs under Node's permission model with read
access to its own folder, and the permission model follows symlinks
and junctions. A signed package carrying a link into the profile
(passwords.vault, the wallet store) would therefore be read access to
the profile. Windows' tar.exe fails to create symlinks only where the
privilege is withheld; with Developer Mode it creates them. Extracted
packages containing any symlink, junction, hard link or special file
are now refused, and an extension whose installed folder or scratch
folder contains one is not started (the sandbox itself cannot create
links: Node refuses symlink creation without full fs permission).
2026-10-05 23:41:30 +02:00

464 lines
24 KiB
JavaScript

// 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>/extensions-staged/<id>-<version>/.
// The NEXT launch's promoteStagedUpdates() moves the staged copy into
// <userData>/extensions/<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;
// A channel's version string ends up in staging paths and temp-file names, so
// only plain dotted numbers are accepted ("1.2.3", not "9/../../x").
const VERSION_RE = /^\d{1,6}(?:\.\d{1,6}){0,3}$/;
// Windows resolves a bare "tar" against the current directory before PATH;
// use the system copy (Win10 1803+) explicitly.
const TAR = process.platform === "win32"
? path.join(process.env.SystemRoot || "C:\\Windows", "System32", "tar.exe")
: "tar";
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.
// `only(manifest)` limits the promotion to some staged entries (the live
// "Install update" path applies extensions in place but leaves plug-ins for
// the next launch). Returns the list of { id, version } actually promoted.
function promoteStagedUpdates({ addonsDir, backupsDir, stagedDir, logger, only }) {
const log = logger || (() => {});
const promoted = [];
if (!fs.existsSync(stagedDir)) return promoted;
let entries;
try { entries = fs.readdirSync(stagedDir, { withFileTypes: true }); }
catch (e) { log("promote: readdir failed:", e?.message); return promoted; }
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;
}
if (typeof only === "function" && !only(manifest)) 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}`); promoted.push({ id: manifest.id, version: 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 }); promoted.push({ id: manifest.id, version: manifest.version }); log(`promote: ${manifest.id} -> ${manifest.version} (via copy)`); }
catch (ee) { log(`promote: move ${manifest.id} failed:`, ee.message); }
}
}
return promoted;
}
// ---------- 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 ----------------------------------------
// Read a channel manifest and pick its best entry. The trust root comes from
// the caller (the installed addon.json, or the catalog card), never from the
// channel itself: with `publisher` set, the entry must name that publisher and
// carry its `publisherSig` (verified by verifyPublisher against the name's NFT
// owner); without it, only the operator's Ed25519 `sig` counts. Otherwise
// whoever can write a channel could sign a bundled add-on's update with any
// name they own.
async function pickChannelEntry({ id, currentVer, updateURL, pubkeysHex, verifyPublisher, publisher, log, timeoutMs }) {
const pub = publisher ? String(publisher).toLowerCase() : null;
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 }; }
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 }; }
const addons = Array.isArray(manifest?.addons) ? manifest.addons : [];
let best = null;
for (const e of addons) {
if (!e?.version || !e?.url || !e?.sha256 || !(pub ? e?.publisherSig : e?.sig)) continue;
if (!VERSION_RE.test(String(e.version))) continue;
if (pub && String(e.publisher || "").toLowerCase() !== pub) continue;
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" };
let trusted = false;
if (!pub && Array.isArray(pubkeysHex) && pubkeysHex.length) trusted = verifySignature(id, best.version, best.sha256, best.sig, pubkeysHex);
if (pub && typeof verifyPublisher === "function") {
try { trusted = !!(await verifyPublisher({ ...best, id })); } catch (e) { log(`updates: publisher verify ${id}@${best.version} threw:`, e.message); }
}
if (!trusted) {
log(`updates: ${id}@${best.version} signature INVALID, skipping`);
return { status: "signature-invalid", newVer: best.version };
}
return { status: "ok", best };
}
// 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 }) {
let tarball;
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 }; }
const gotHash = crypto.createHash("sha256").update(tarball).digest("hex");
if (gotHash.toLowerCase() !== String(sha256).toLowerCase()) {
log(`updates: ${id}@${version} sha256 mismatch (${gotHash} vs ${sha256}), skipping`);
return { status: "sha256-mismatch", newVer: version };
}
const tag = `${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
const tmpFile = path.join(os.tmpdir(), `sm-addon-${id}-${version}-${tag}.tgz`);
const tmpDir = path.join(os.tmpdir(), `sm-addon-${id}-${version}-${tag}.dir`);
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;
}
}
} catch (e) {
log(`updates: extract ${id}@${version} failed:`, e.message);
try { fs.rmSync(tmpFile, { force: true }); } catch {}
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
return { status: "extract-failed", newVer: version, detail: e.message };
}
// No links of any kind. A community extension runs sandboxed with read
// access to its own folder, and Node's permission model follows links, so
// a packaged symlink or junction to the profile (passwords.vault, the
// wallet store) would be a way out. tar.exe only fails to create symlinks
// where Windows withholds the privilege; with Developer Mode it makes them.
const links = findLinks(tmpDir);
if (links.length) {
log(`updates: ${id}@${version} contains links (${links.slice(0, 3).join(", ")}), refusing`);
try { fs.rmSync(tmpFile, { force: true }); } catch {}
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
return { status: "extract-failed", newVer: version, detail: "package contains links" };
}
// 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`);
try { fs.rmSync(tmpFile, { force: true }); } catch {}
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
return { status: "manifest-mismatch", newVer: version, detail: `got ${extracted?.id}@${extracted?.version}` };
}
return { ok: true, tmpDir: root, tmpTop: tmpDir, tmpFile, manifest: extracted };
}
// Every symlink, junction or hard-linked file under root (relative paths).
// lstat reports a junction as a symbolic link; it is not followed.
function findLinks(root) {
const out = [];
const walk = (dir, rel) => {
let entries = [];
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
for (const de of entries) {
const p = path.join(dir, de.name), r = rel ? rel + "/" + de.name : de.name;
let st;
try { st = fs.lstatSync(p); } catch { out.push(r); continue; }
if (st.isSymbolicLink()) { out.push(r); continue; }
if (st.isDirectory()) { walk(p, r); continue; }
if (st.isFile() && st.nlink > 1) out.push(r);
else if (!st.isFile()) out.push(r);
}
};
walk(root, "");
return out;
}
// 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 }); }
}
// A community (publisher-signed) update is placed under the same rules as a
// community install: it cannot claim first-party placement (category) or
// another add-on's key namespace (absorbs), and it cannot widen what it may
// do — new capabilities or new page-inject origins — without the user's
// consent, which only a reinstall from theseus.x asks for. Such an update is
// not staged; the installed version keeps running.
function communityUpdateProblem(oldMf, newMf) {
const oldCaps = new Set(Array.isArray(oldMf?.capabilities) ? oldMf.capabilities : []);
const added = (Array.isArray(newMf?.capabilities) ? newMf.capabilities : []).filter((c) => !oldCaps.has(c));
if (added.length) return `asks for new capabilities (${added.join(", ")})`;
const origins = (m) => JSON.stringify(((m && m["page-inject"] && m["page-inject"].origins) || []).slice().sort());
const oldPre = oldMf && oldMf["page-inject"] && oldMf["page-inject"].preload;
const newPre = newMf && newMf["page-inject"] && newMf["page-inject"].preload;
if ((newPre && !oldPre) || origins(oldMf) !== origins(newMf) && newPre) return "changes which pages it is injected into";
return null;
}
async function stageOne({ id, currentVer, updateURL, stagedDir, pubkeysHex, verifyPublisher, publisher, currentManifest, log, timeoutMs }) {
const picked = await pickChannelEntry({ id, currentVer, updateURL, pubkeysHex, verifyPublisher, publisher, 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;
if (publisher) {
const problem = communityUpdateProblem(currentManifest, pkg.manifest);
if (problem) {
log(`updates: ${id}@${best.version} not staged — it ${problem}; reinstall it from theseus.x to approve`);
try { fs.rmSync(pkg.tmpTop, { recursive: true, force: true }); } catch {}
try { fs.rmSync(pkg.tmpFile, { force: true }); } catch {}
return { status: "needs-consent", newVer: best.version, detail: problem };
}
try {
const mf = { ...pkg.manifest };
delete mf.category; delete mf.absorbs;
mf.publisher = best.publisher || publisher;
if (!mf.updateURL) mf.updateURL = updateURL;
fs.writeFileSync(path.join(pkg.tmpDir, "addon.json"), JSON.stringify(mf, null, 2));
} catch (e) { log(`updates: ${id} manifest rewrite failed:`, e?.message); return { status: "extract-failed", newVer: best.version, detail: "manifest rewrite" }; }
}
try { fs.mkdirSync(stagedDir, { recursive: true }); } catch {}
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 {}
log(`updates: staged ${id}@${best.version} — will apply on next launch`);
return { status: "staged", newVer: best.version, stagePath: stageOut };
}
// ---------- 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, publisher, addonsDir, backupsDir, verifyPublisher, log = () => {}, timeoutMs = 15000 }) {
if (typeof verifyPublisher !== "function") return { ok: false, error: "no publisher verifier" };
if (!publisher) return { ok: false, error: "catalog entry has no publisher" };
const dest = path.join(addonsDir, id);
const current = readAddonJson(dest);
// An id that is already installed belongs to whoever signed it — an
// operator-signed add-on (no publisher) or another publisher's extension
// can't be replaced by a catalog entry that reuses its id.
if (current && String(current.publisher || "").toLowerCase() !== String(publisher).toLowerCase()) {
return { ok: false, error: `"${id}" is already installed from another publisher` };
}
const currentVer = current?.version || null;
const picked = await pickChannelEntry({ id, currentVer, updateURL: updatesUrl, pubkeysHex: [], verifyPublisher, publisher, 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;
// First-party plug-in placement is not something a package can claim.
delete mf.category; delete mf.absorbs;
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.
async function checkAndStageUpdates({ addonsDir, stagedDir, pubkeysHex, verifyPublisher, timeoutMs = 15000, logger }) {
const log = logger || (() => {});
const report = [];
if ((!Array.isArray(pubkeysHex) || pubkeysHex.length === 0) && typeof verifyPublisher !== "function") {
log("updates: no operator pubkeys configured — skipping update check");
return { report, skipped: "no-pubkeys" };
}
let entries;
try { entries = fs.readdirSync(addonsDir, { withFileTypes: true }); }
catch { return { report, skipped: "no-addons-dir" }; }
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;
}
pending.push(
stageOne({
id: manifest.id,
currentVer: manifest.version,
updateURL: manifest.updateURL,
publisher: manifest.publisher || null,
currentManifest: manifest,
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 }); })
);
}
await Promise.all(pending);
return { report };
}
module.exports = { communityUpdateProblem, findLinks,
cmpVer,
promoteStagedUpdates,
checkAndStageUpdates,
installCommunity,
verifySignature,
SIG_DOMAIN,
};