igneum/docs/plans/ui-ota.md
igneum-labs e02f5e14d0 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>
2026-10-07 10:28:57 +00:00

9.7 KiB

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//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