ui-ota: the publisher (tools/ui-ota/publish.mjs), publish-manifest.sh --ui and --no-ui, the plan with the security notes

publish.mjs packs the fifteen served files with one fixed mtime (reproducible; the self-test checks it), hashes, signs
the entry through igneum-ota-sign sign-ui with the key in ~/.config/igneum (never read or printed here), copies the
bundle into the folder's ui/ and hands ui.json to publish-manifest.sh --ui, the one writer of the signed manifest,
which verifies the entry and the bundle's hash before signing; --dry-run writes nothing, --verify reads the live
manifest back against dl/<token>/ui and dl/public/ui; --no-ui withdraws the channel. Both self-tests sit on the one
gate. docs/plans/ui-ota.md: the shape, the engine, the security notes, the operator recipe, the tests, per tier.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-07 10:28:03 +00:00
parent 36306fe901
commit a1d6eecb33
4 changed files with 328 additions and 4 deletions

121
docs/plans/ui-ota.md Normal file
View file

@ -0,0 +1,121 @@
# The interface over the air: smaller UI changes without a new version
7 October 2026, 10:5x UK. the project lead: "can we make smaller UI based updates over the air without a new version being
needed?" Branch `ui-ota`, worktree `../igneum-wt-ui-ota`, off the miner-ui-5 tip e9fe1106; the shipper takes it into
0.3.20. The dashboard (app/igneum-app/ui: index.html, app.js, app.css, live-dag.js, proof-core.js, the fonts, the
mark) is embedded in the engine binary today; from 0.3.20 a signed bundle of the same folder can replace it between
app versions. The engine, the node, the miner and the workers never move this way: a bundle is files the engine
serves on 127.0.0.1, nothing it runs.
## 1. What a miner sees
| Where | What |
|---|---|
| Nothing, usually | The hourly manifest check finds an interface bundle, downloads about 400 KB, checks it, swaps it in; the open window reloads itself the next second it is idle (no input focused, no sheet or update card open, not mid-setup). |
| Settings > Interface | "Interface 1.0.1, over the air, 7 Oct 2026" or "Interface 1.0.0, built in"; the help line says what is pending, refused, held back or skipped. |
| Settings > Interface, the switch | "Use the built-in interface": the embedded dashboard serves even when a bundle is active; off again takes the bundle at the next load. |
| Activity | "interface 1.0.1 is published: downloading (412 KB)", "interface 1.0.1 installed; the window takes it at its next load", or "interface 1.0.1 was refused: ... The built-in interface serves". |
## 2. The manifest's interface channel
The signed manifest (igneum-app-latest.json, src/manifest.rs) gains one object, covered by the manifest's own
signature like every other field:
```
"ui": { "version": "1.0.1", "sha256": "<64 hex>", "size": 412345,
"url": "https://dl.igneum.network/dl/<token>/ui/igneum-ui-1.0.1.tar.gz",
"min_engine": "0.3.20", "signature": "<128 hex>" }
```
| Field | Rule |
|---|---|
| version | the interface's own three-part line (1.0.0 is the one embedded in 0.3.20; ui/VERSION in the tree) |
| sha256, size | of the tar.gz; both checked after the download |
| url | https on the downloads host, under ui/; a bundle is never fetched from anywhere the manifest did not name |
| min_engine | the lowest app version that may serve it; an engine below it ignores the entry and says so in its log |
| signature | Ed25519 by the release key (the public half pinned in src/manifest.rs) over `igneum-ui\n<version>\n<sha256>\n<min_engine>\n` (`manifest::ui_sign_bytes`); the bundle stays verifiable on its own, and the shipper's point stands: the manifest signature already covers it, this one is the belt to that brace |
The kill switch is the manifest without a `ui` object (`publish-manifest.sh --no-ui`): every engine retires its bundle
at the next check and the built-in interface serves. A bundle that fails any check is marked bad on that machine
and never applied again; the next version is a new chance.
## 3. The engine (src/uiota.rs, src/ota.rs, src/server.rs)
| Step | What happens |
|---|---|
| decide | `uiota::decide`: skip when the built-in interface is chosen, when min_engine is newer than the engine, when the version was marked bad, when it is the active one, or when it is the embedded one |
| download | curl with resume into `<app data>/ui/<version>.tar.gz`; size and sha256 against the entry; the entry's signature against the pinned key |
| unpack | the system tar into `<app data>/ui/<version>.new`; index.html, app.js and app.css must be there; the bundle's VERSION must say the manifest's version; renamed to `<app data>/ui/<version>` |
| swap | `<app data>/ui/current.json` (written to .tmp, renamed) names the version; the server reads the pointer through `Shared::ui_dir` and serves the bundle's files for the fixed names in `uiota::SERVED` (nothing else is read from the folder; a file the bundle lacks falls back to the embedded one); the embedded files stay in the binary |
| reload | `state.ui.active_version` changes; the page compares it with the version it loaded (`View.shouldReload`) and reloads when idle |
| confirm | the first page load from an unconfirmed bundle starts a 10 s wait; the page POSTs `/api/ui/health` after its first paint (a `window.error` before that posts the error instead); a ping confirms, an error or silence rolls back |
| roll back | the pointer goes back to "embedded", the version lands in `<app data>/ui/bad.json`, one Activity line, one log line (`ui: <version> marked bad: <why>`) |
| state | `state.ui` {embedded_version, active_version, source, installed_at, confirmed, published_version, busy, error, bad, builtin, note}; `settings.ui_builtin` |
Log lines the shipper reads back: `ui bundle <version> active (installed <unix>)`, `ui bundle <version> confirmed
by the page`, `ui: <version> marked bad: <why>`, `ui: no ui object in the manifest; interface <version> retired`.
## 4. Security notes
- Same origin: the bundle is served by this engine on 127.0.0.1 under the per-launch token path, exactly as the
embedded files are; the page's fetches, the dashboard's POST rule (`from_dashboard`) and the token are unchanged.
- No remote scripts: the bundle is a copy of the tree's ui folder; index.html loads only its own files; the
test `ui-ota.test.mjs` fails on a `<script src="http...">`. The engine never evaluates anything from the bundle;
it serves bytes.
- The signature is the only trust: the manifest's Ed25519 signature (the pinned release key) covers the entry, the
entry's own signature covers version, hash and engine floor, and the hash covers the bytes. No bundle is applied on
a hash alone, and the URL is never trusted beyond "where to fetch".
- Fixed names: only the fifteen served names are ever read from a bundle folder (`uiota::SERVED`); a path is never
built from a request.
- The kill switch: removing the `ui` object, or publishing a bundle whose min_engine is above every engine.
- A bundle cannot brick a window: no index.html, a parse error in app.js, a JS error on first paint or a page that
never answers all roll back within 10 s of the first load, and the version is never retried on that machine.
- The private key stays in `~/.config/igneum/ota-signing-key`; tools/ui-ota/publish.mjs calls igneum-ota-sign and
never reads or prints the key; tools/ci/no-secrets-check.sh keeps the name out of the tree.
## 5. The publisher (tools/ui-ota/publish.mjs, packaging/ota/publish-manifest.sh --ui)
The one-line operator recipe, staged first as every cut is (the live folder changes only in the deploy step):
```
IGNEUM_DLSITE=<scratch copy of the downloads folder> node tools/ui-ota/publish.mjs --version 1.0.1 --min-engine 0.3.20 --notes "one line" --dry-run
node tools/ui-ota/publish.mjs --version 1.0.1 --min-engine 0.3.20 --notes "one line" --deploy --public
node tools/ui-ota/publish.mjs --verify
```
What it does: writes ui/VERSION from --version, packs the fifteen served files in sorted order with one fixed mtime
(the same tree gives the same bytes; the self-test checks it), hashes, signs the entry through
`igneum-ota-sign sign-ui`, copies the tar into the folder's ui/, writes ui.json and calls
`publish-manifest.sh --version <the folder's app version> --ui ui.json`, which verifies the entry's signature and the
bundle's hash in the folder before it signs the manifest. `--dry-run` stops after the pack and the entry (nothing
signed, nothing written to any downloads folder). `--verify` reads the live manifest, checks the bundle in the
folder's dl/<token>/ui and dl/public/ui against ui.sha256 and the entry's signature, and exits 1 on any miss: the
read-back the shipper asked for. `--no-ui` on publish-manifest.sh withdraws the channel.
## 6. Tests and gates
| Test | Where |
|---|---|
| a good bundle installs, the pointer swaps atomically | `uiota::tests::a_good_bundle_installs_and_the_pointer_swaps_atomically` |
| a bad signature, a tampered hash, a wrong size are refused before anything is unpacked | `uiota::tests::a_bad_signature_is_refused_before_anything_is_unpacked` |
| a too-new min_engine is ignored, and every other skip | `uiota::tests::the_decision_covers_every_reason_to_skip` |
| a broken bundle (no index.html) is refused and leaves nothing behind; a renamed bundle is refused by its VERSION | `uiota::tests::a_broken_bundle_...`, `a_renamed_bundle_...` |
| the health wait rolls back to the embedded interface and marks the version bad; a first-paint error too; a ping confirms | `uiota::tests::the_health_wait_rolls_back_...` |
| the ui entry parses, every bad field fails, the entry's signature breaks on any change | `manifest::tests::the_ui_entry_parses_and_every_bad_field_fails` |
| the Settings words, the reload rule, the health ping and no remote script | `ui/ui-ota.test.mjs` (on the one gate) |
| the pack is reproducible and complete, a good entry verifies, a tampered hash and a wrong key do not | `node tools/ui-ota/publish.mjs --self-test` (on the one gate; the sign half runs where the signer is built) |
## 7. Per tier
Every tier the same: the bundle is about 400 KB (the fonts are most of it), one download an hour at most, no
restart, no mining pause; a machine without a window (a rig, a pool box) swaps the pointer and confirms at the next
window open; Windows, Linux and macOS alike (the system tar on all three); a second engine (`--sweep`) never updates,
so it never swaps an interface either.
## 8. Owed
| What | Who |
|---|---|
| The first real publish (1.0.1) once 0.3.20 is on every platform, with the read-back line in the release notes | the shipper |
| A CI check that the live manifest's ui.sha256 equals the tar in dl/public (`publish.mjs --verify` is the hand form; CI has no dlsite folder) | the shipper's post-deploy step |
| Captures of Settings > Interface in both states for the site | the app lane, after its rebase |

View file

@ -17,6 +17,10 @@
# [--tuning tuning.json | --no-tuning] the fleet's per-card kernel tuning (tools/tuning.mjs writes it;
# docs/design/miner-tuning.md); carried over from the current
# manifest when not given, as is consensus.override
# [--ui ui.json | --no-ui] the interface channel (tools/ui-ota/publish.mjs writes ui.json:
# {version, sha256, size, url, min_engine, signature}, the bundle
# already in the folder's ui/); carried over when not given;
# --no-ui withdraws it (the kill switch; docs/plans/ui-ota.md)
#
# A platform you do not pass is carried over from the manifest already in the folder when that one has the same
# version (the Windows build lands later than the Mac one: publish the Mac entry first, add the Windows entry when
@ -59,7 +63,7 @@ if [ "${1:-}" = "--self-test-height" ]; then
height_rule "" 1000 || { echo "self-test failed: no height was refused"; exit 1; }
echo "self-test passed: a height at or below the live DAA is refused, a future one and none pass"; exit 0
fi
OVERRIDE="" TUNING_FILE="" NO_TUNING=0 PUBLIC=0
OVERRIDE="" TUNING_FILE="" NO_TUNING=0 PUBLIC=0 UI_FILE="" NO_UI=0
while [ $# -gt 0 ]; do
case "$1" in
--version) VERSION="$2"; shift 2 ;;
@ -73,6 +77,8 @@ while [ $# -gt 0 ]; do
--channel) CHANNEL="$2"; shift 2 ;;
--tuning) TUNING_FILE="$2"; shift 2 ;;
--no-tuning) NO_TUNING=1; shift ;;
--ui) UI_FILE="$2"; shift 2 ;;
--no-ui) NO_UI=1; shift ;;
--base-url) BASE="$2"; shift 2 ;;
--dest) DEST="$2"; shift 2 ;;
--deploy) DEPLOY=1; shift ;;
@ -198,11 +204,31 @@ elif [ "$NO_TUNING" = 0 ] && [ -f "$OLD" ]; then
[ -n "$TUNING" ] && echo "tuning: carried over from the current manifest ($(python3 -c 'import json,sys; print(len(json.loads(sys.argv[1])["cards"]))' "$TUNING") card model(s))"
fi
# ui: the interface channel, given here (ui.json from tools/ui-ota/publish.mjs, its own signature checked with the
# signer and its bundle present in the folder), else carried over; --no-ui withdraws it
UI=""
if [ -n "$UI_FILE" ]; then
[ -f "$UI_FILE" ] || { echo "missing: $UI_FILE" >&2; exit 1; }
UI="$(python3 -c 'import json,sys; u=json.load(open(sys.argv[1])); need=("version","sha256","size","url","min_engine","signature"); assert all(k in u for k in need), "ui.json needs "+", ".join(need); print(json.dumps({k:u[k] for k in need}, sort_keys=True, separators=(",",":")))' "$UI_FILE")"
read -r UI_VERSION UI_SHA UI_MIN UI_SIG UI_URL < <(python3 -c 'import json,sys; u=json.loads(sys.argv[1]); print(u["version"], u["sha256"], u["min_engine"], u["signature"], u["url"])' "$UI")
"$SIGNER" verify-ui "$PUB" "$UI_VERSION" "$UI_SHA" "$UI_MIN" "$UI_SIG" >/dev/null || { echo "ui.json: the interface signature does not verify against $PUB" >&2; exit 1; }
UI_NAME="$(basename "$UI_URL")"
[ -f "$DEST/ui/$UI_NAME" ] || { echo "ui: the bundle $DEST/ui/$UI_NAME is not in the folder (tools/ui-ota/publish.mjs copies it)" >&2; exit 1; }
read -r got_sum _ < <("$SIGNER" sha256 "$DEST/ui/$UI_NAME")
[ "$got_sum" = "$UI_SHA" ] || { echo "ui: $DEST/ui/$UI_NAME has sha256 $got_sum, ui.json says $UI_SHA" >&2; exit 1; }
echo "ui: interface $UI_VERSION (min engine $UI_MIN) at $UI_NAME, signature OK"
elif [ "$NO_UI" = 0 ] && [ -f "$OLD" ]; then
UI="$(python3 -c 'import json,sys; u=json.load(open(sys.argv[1])).get("ui"); print(json.dumps(u, sort_keys=True, separators=(",",":")) if isinstance(u, dict) and u.get("version") else "")' "$OLD" 2>/dev/null || true)"
[ -n "$UI" ] && echo "ui: carried over from the current manifest (interface $(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["version"])' "$UI"))"
elif [ "$NO_UI" = 1 ]; then
echo "ui: withdrawn (--no-ui): every app falls back to its built-in interface at its next check"
fi
# canonical JSON: sorted keys, no whitespace; the signature is over these exact bytes
NEW="$DEST/igneum-app-latest.json.new"
python3 - "$NEW" "$VERSION" "$CHANNEL" "$NOTES" "$MIN_SUPPORTED" "$ACTIVATION" "$DEADLINE" "$MAC_ENTRY" "$WIN_ENTRY" "${OVERRIDE:-}" "${TUNING:-}" <<'PY'
python3 - "$NEW" "$VERSION" "$CHANNEL" "$NOTES" "$MIN_SUPPORTED" "$ACTIVATION" "$DEADLINE" "$MAC_ENTRY" "$WIN_ENTRY" "${OVERRIDE:-}" "${TUNING:-}" "${UI:-}" <<'PY'
import json, sys, datetime
out, version, channel, notes, min_supported, activation, deadline, mac, win, override, tuning = sys.argv[1:12]
out, version, channel, notes, min_supported, activation, deadline, mac, win, override, tuning, ui = sys.argv[1:13]
override = json.loads(override) if override else None
if override is not None and (not isinstance(override, dict) or not override): raise SystemExit("--override must be a non-empty JSON object")
def entry(s):
@ -220,6 +246,8 @@ m = {
}
if tuning:
m["tuning"] = json.loads(tuning)
if ui:
m["ui"] = json.loads(ui)
open(out, "w").write(json.dumps(m, sort_keys=True, separators=(",", ":"), ensure_ascii=False))
PY
"$SIGNER" sign "$KEY" "$NEW" > "$NEW.sig"

View file

@ -78,8 +78,9 @@ tree_checks() {
run "faucet unit tests" node --test site/api/faucet.test.mjs
run "explorer, emission and public stats unit tests" node --test site/lib/explorer.test.mjs site/lib/emission.test.mjs site/api/public-stats.test.mjs
run "ship tool self-test" node tools/ship-app.mjs --self-test
run "interface bundle publisher self-test (pack, and sign when the signer is built)" node tools/ui-ota/publish.mjs --self-test
run "relay unit tests" node --test relay/test/parse.test.mjs relay/test/auth.test.mjs relay/test/wake.test.mjs relay/test/ember.test.mjs
run "miner app notice strip and update card tests" node --test app/igneum-app/ui/notices.test.mjs app/igneum-app/ui/update-card.test.mjs app/igneum-app/ui/view.test.mjs app/igneum-app/ui/tune-line.test.mjs
run "miner app notice strip and update card tests" node --test app/igneum-app/ui/notices.test.mjs app/igneum-app/ui/update-card.test.mjs app/igneum-app/ui/view.test.mjs app/igneum-app/ui/tune-line.test.mjs app/igneum-app/ui/ui-ota.test.mjs
run "no secret file names and no 64-hex secrets in the tree" bash -c 'bash tools/ci/no-secrets-check.sh --self-test && bash tools/ci/no-secrets-check.sh'
}

174
tools/ui-ota/publish.mjs Normal file
View file

@ -0,0 +1,174 @@
#!/usr/bin/env node
// The interface over the air, publisher side (docs/plans/ui-ota.md): packs app/igneum-app/ui into one tar.gz, hashes
// it, signs the entry with the release key through igneum-ota-sign (the key stays in ~/.config/igneum, never here),
// copies the bundle into the downloads folder's ui/ and hands the channel to packaging/ota/publish-manifest.sh --ui,
// the one writer of the signed manifest. Nothing here deploys: --deploy is passed through to publish-manifest.sh,
// which deploys from the live folder; a cut is staged in a scratch copy (IGNEUM_DLSITE) as every other publish.
//
// node tools/ui-ota/publish.mjs --version 1.0.1 --min-engine 0.3.19 [--notes "one line"] [--dry-run] [--deploy] [--public]
// node tools/ui-ota/publish.mjs --verify [--url https://dl.igneum.network/dl/<token>/igneum-app-latest.json]
// the live manifest's ui entry against the bundle in the folder (the read-back)
// node tools/ui-ota/publish.mjs --self-test pack, hash, sign and verify with a throwaway key in a scratch folder
//
// --dry-run packs, hashes and signs into a scratch folder and prints the entry; nothing is written to any downloads
// folder and publish-manifest.sh is not called. The bundle's VERSION file is written from --version before packing.
// The version is three-part (the publish rule); min-engine is the lowest app version that may serve it.
import { execFileSync, spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '../..');
const uiDir = path.join(root, 'app/igneum-app/ui');
const signer = path.join(root, 'app/igneum-app/target/release/igneum-ota-sign');
const keyFile = path.join(os.homedir(), '.config/igneum/ota-signing-key');
const pubFile = path.join(os.homedir(), '.config/igneum/ota-signing-key.pub');
/// what goes into the bundle: the served files (src/uiota.rs SERVED), never the tests
export const FILES = ['index.html', 'app.css', 'app.js', 'mark.svg', 'live-dag.js', 'proof-core.js', 'VERSION', 'fonts/IBMPlexMono-400.woff2', 'fonts/IBMPlexMono-500.woff2', 'fonts/IBMPlexSans-400.woff2', 'fonts/IBMPlexSans-500.woff2', 'fonts/IBMPlexSans-600.woff2', 'fonts/Unbounded-500.woff2', 'fonts/Unbounded-700.woff2', 'fonts/Unbounded-900.woff2'];
function arg(name, d) { const i = process.argv.indexOf(name); return i >= 0 && process.argv[i + 1] && !process.argv[i + 1].startsWith('--') ? process.argv[i + 1] : d; }
const flag = (name) => process.argv.includes(name);
// the mtime every packed file carries (1 January 2026 UTC), so a pack is a function of the tree alone
const STAMP = new Date(Date.UTC(2026, 0, 1));
const threePart = (v) => /^\d+\.\d+\.\d+$/.test(v);
export function sha256(file) { return createHash('sha256').update(fs.readFileSync(file)).digest('hex'); }
// Copies the served files into a clean folder with the VERSION stamped, packs them in sorted order (one tar, the
// system's; gzip -n keeps the archive free of a timestamp), returns {tar, sha256, size}.
export function pack(srcDir, version, outDir, files = FILES) {
const stage = path.join(outDir, 'stage');
fs.rmSync(stage, { recursive: true, force: true });
for (const f of files) {
const from = path.join(srcDir, f), to = path.join(stage, f);
fs.mkdirSync(path.dirname(to), { recursive: true });
if (f === 'VERSION') fs.writeFileSync(to, version + '\n'); else fs.copyFileSync(from, to);
fs.utimesSync(to, STAMP, STAMP); // one mtime for every file: the same tree packs to the same bytes
}
fs.utimesSync(stage, STAMP, STAMP);
fs.utimesSync(path.join(stage, 'fonts'), STAMP, STAMP);
const tar = path.join(outDir, `igneum-ui-${version}.tar`);
const sorted = [...files].sort();
execFileSync('tar', ['-cf', tar, '-C', stage, ...sorted]);
fs.rmSync(tar + '.gz', { force: true });
execFileSync('gzip', ['-n', '-9', tar]);
const gz = tar + '.gz';
return { tar: gz, sha256: sha256(gz), size: fs.statSync(gz).size };
}
function run(cmd, args) {
// the Mac's cargo lives in ~/.cargo/bin (the one publish-manifest.sh uses); node's PATH may hold an older one
const env = { ...process.env, PATH: path.join(os.homedir(), '.cargo/bin') + path.delimiter + (process.env.PATH || '') };
const r = spawnSync(cmd, args, { encoding: 'utf8', env });
if (r.status !== 0) throw new Error(`${path.basename(cmd)} ${args.filter((a) => !a.includes('ota-signing-key')).join(' ')}: ${(r.stderr || r.stdout || '').trim()}`);
return r.stdout.trim();
}
export function signEntry(signerBin, key, version, sha, minEngine) { return run(signerBin, ['sign-ui', key, version, sha, minEngine]); }
export function verifyEntry(signerBin, pub, e) { return run(signerBin, ['verify-ui', pub, e.version, e.sha256, e.min_engine, e.signature]) === 'verifies'; }
function ensureSigner() {
if (fs.existsSync(signer)) return signer;
console.log('building igneum-ota-sign');
run('cargo', ['build', '--release', '-j', '4', '--bin', 'igneum-ota-sign', '--quiet', '--manifest-path', path.join(root, 'app/igneum-app/Cargo.toml')]);
return signer;
}
function selfTest() {
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'ui-ota-selftest-'));
const fails = [];
try {
const a = pack(uiDir, '9.9.9', path.join(scratch, 'a'));
const b = pack(uiDir, '9.9.9', path.join(scratch, 'b'));
if (a.sha256 !== b.sha256) fails.push('two packs of the same tree differ (the bundle is not reproducible)');
if (a.size < 100_000) fails.push('the bundle is too small to hold the fonts: ' + a.size);
const list = execFileSync('tar', ['-tzf', a.tar], { encoding: 'utf8' }).trim().split('\n').sort();
for (const f of FILES) if (!list.includes(f)) fails.push('the bundle lacks ' + f);
if (list.some((f) => f.endsWith('.test.mjs'))) fails.push('a test file went into the bundle');
const v = fs.readFileSync(path.join(scratch, 'a/stage/VERSION'), 'utf8').trim();
if (v !== '9.9.9') fails.push('the VERSION file was not stamped: ' + v);
// the signing half needs the built signer (the Rust tests cover verify_ui_entry; CI has no signer binary)
if (!fs.existsSync(signer)) { fs.rmSync(scratch, { recursive: true, force: true }); if (fails.length) { console.error('self-test failed:\n ' + fails.join('\n ')); process.exit(1); } console.log('self-test passed (pack half): the pack is reproducible and complete; no signer binary here, the sign half is skipped'); return; }
const s = signer;
const key = path.join(scratch, 'key'), pub = path.join(scratch, 'key.pub');
run(s, ['keygen', key, pub]);
const sig = signEntry(s, key, '9.9.9', a.sha256, '0.3.19');
const e = { version: '9.9.9', sha256: a.sha256, size: a.size, url: 'https://dl.igneum.network/dl/x/ui/igneum-ui-9.9.9.tar.gz', min_engine: '0.3.19', signature: sig };
if (!verifyEntry(s, pub, e)) fails.push('a good entry did not verify');
let bad = false; try { bad = verifyEntry(s, pub, { ...e, sha256: '00'.repeat(32) }); } catch (x) { bad = false; }
if (bad) fails.push('a tampered hash verified');
let other = false; const key2 = path.join(scratch, 'key2'), pub2 = path.join(scratch, 'key2.pub'); run(s, ['keygen', key2, pub2]);
try { other = verifyEntry(s, pub2, e); } catch (x) { other = false; }
if (other) fails.push('an entry verified against the wrong key');
} catch (x) { fails.push(String(x.message || x)); }
fs.rmSync(scratch, { recursive: true, force: true });
if (fails.length) { console.error('self-test failed:\n ' + fails.join('\n ')); process.exit(1); }
console.log('self-test passed: the pack is reproducible and complete, a good entry verifies, a tampered hash and a wrong key do not');
}
function verifyLive() {
const token = fs.readFileSync(path.join(os.homedir(), '.config/igneum/dl-token'), 'utf8').trim();
const url = arg('--url', `https://dl.igneum.network/dl/${token}/igneum-app-latest.json`);
const dlsite = process.env.IGNEUM_DLSITE || fs.readFileSync(path.join(os.homedir(), '.config/igneum/dlsite-dir'), 'utf8').trim();
const text = run('curl', ['-fsSL', '--max-time', '20', url]);
const m = JSON.parse(text);
const mask = (t) => String(t).split(token).join('<token>');
if (!m.ui) { console.log('live manifest: no ui object (the built-in interface serves everywhere)'); return; }
const name = path.basename(m.ui.url);
const local = path.join(dlsite, 'dl', token, 'ui', name), pub = path.join(dlsite, 'dl', 'public', 'ui', name);
const lines = [`live manifest: interface ${m.ui.version}, min engine ${m.ui.min_engine}, ${mask(m.ui.url)}`];
let ok = true;
for (const f of [local, pub]) {
if (!fs.existsSync(f)) { lines.push(` ${mask(f)}: missing`); if (f === local) ok = false; continue; }
const sum = sha256(f);
lines.push(` ${mask(f)}: sha256 ${sum === m.ui.sha256 ? 'matches' : 'DIFFERS (' + sum + ')'}`);
if (sum !== m.ui.sha256) ok = false;
}
const s = ensureSigner();
const sigOk = verifyEntry(s, pubFile, m.ui);
lines.push(` signature: ${sigOk ? 'verifies' : 'DOES NOT VERIFY'}`);
console.log(lines.join('\n'));
if (!ok || !sigOk) process.exit(1);
}
function main() {
if (flag('--self-test')) return selfTest();
if (flag('--verify')) return verifyLive();
const version = arg('--version'), minEngine = arg('--min-engine'), notes = arg('--notes', '');
if (!version || !threePart(version)) { console.error('--version major.minor.patch is required (the interface has its own line, 1.0.0 upward)'); process.exit(2); }
if (!minEngine || !threePart(minEngine)) { console.error('--min-engine major.minor.patch is required (the lowest app version that may serve this bundle)'); process.exit(2); }
const dry = flag('--dry-run');
const s = ensureSigner();
const token = fs.readFileSync(path.join(os.homedir(), '.config/igneum/dl-token'), 'utf8').trim();
const dlsite = process.env.IGNEUM_DLSITE || fs.readFileSync(path.join(os.homedir(), '.config/igneum/dlsite-dir'), 'utf8').trim();
const dest = path.join(dlsite, 'dl', token);
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'ui-ota-'));
const p = pack(uiDir, version, scratch);
const name = path.basename(p.tar);
const url = `https://dl.igneum.network/dl/${token}/ui/${name}`;
const entry = { version, sha256: p.sha256, size: p.size, url, min_engine: minEngine, signature: dry ? '(signed at publish)' : signEntry(s, keyFile, version, p.sha256, minEngine) };
const shown = { ...entry, url: url.split(token).join('<token>') };
console.log('interface bundle: ' + JSON.stringify(shown, null, 1));
if (dry) { console.log(`dry run: nothing written to ${dest.split(token).join('<token>')}; the bundle is at ${p.tar}`); return; }
if (!verifyEntry(s, pubFile, entry)) { console.error('the entry does not verify against ' + pubFile); process.exit(1); }
fs.mkdirSync(path.join(dest, 'ui'), { recursive: true });
fs.copyFileSync(p.tar, path.join(dest, 'ui', name));
const uiJson = path.join(scratch, 'ui.json');
fs.writeFileSync(uiJson, JSON.stringify(entry));
const pm = path.join(root, 'packaging/ota/publish-manifest.sh');
const args = ['--version', arg('--app-version', readFolderVersion(dest)), '--ui', uiJson, '--notes', notes || `interface ${version} over the air`];
if (flag('--deploy')) args.push('--deploy');
if (flag('--public')) args.push('--public');
console.log(`publish-manifest.sh ${args.join(' ').split(token).join('<token>')}`);
const r = spawnSync('bash', [pm, ...args], { stdio: 'inherit', env: { ...process.env, IGNEUM_DLSITE: dlsite } });
process.exit(r.status || 0);
}
function readFolderVersion(dest) {
try { return JSON.parse(fs.readFileSync(path.join(dest, 'igneum-app-latest.json'), 'utf8')).version; } catch (e) { console.error('no manifest in the folder: pass --app-version'); process.exit(2); }
}
if (import.meta.url === `file://${process.argv[1]}`) main();