Over-the-air updates

A bext-lite app is the native runtime plus a dist-lite/ payload of interpreted JS bundles. That split gives two clean update tiers:

  • Tier 1 - content OTA. Swap the dist-lite/ payload and you update the whole app - routes, API, UI, logic - with no native binary change. This is the Expo/CodePush model. One mechanism serves desktop and mobile because both embed the same runtime.
  • Tier 2 - native shell OTA. Update the bext-lite binary itself. On desktop that is a signed Tauri updater; on mobile it is a normal App Store / Play Store update.

Tier 1 covers day-to-day releases. Tier 2 is only for when the native layer changes (the engine, the Rust host).

Signing#

Every content bundle is ed25519-signed by the publisher, and the runtime refuses anything that does not verify. OTA without signature verification is a remote-code-execution hole, so signing is mandatory, not optional.

Generate a keypair once. Keep the private key offline; ship the public key in the runtime config.

bext-lite keygen
# prints: <private_hex>  <public_hex>

Build and sign a release from a dist-lite/ directory:

bext-lite publish ./dist-lite \
  --key <private_hex_or_file> \
  --version 1.4.0 \
  --out ./release \
  [--min-runtime 1.2.0] [--url https://cdn.example.com/bundle.bin]

This writes two files into ./release:

  • release.json - the signed manifest (version, sha256, size, sig, and optionally url, notes, minRuntime).
  • bundle.bin - the dependency-free pack archive of the payload.

The signature covers version plus the archive sha256, domain-separated as bext-lite-ota:v1:<version>:<sha256>, so it binds identity and content together and cannot be replayed in another context.

Running with OTA#

Point the runtime at the release manifest and give it the publisher's public key:

bext-lite serve ./dist-lite \
  --update-url https://cdn.example.com/release.json \
  --pubkey <public_hex> \
  [--data-dir /var/lib/myapp]

Both http(s):// and file:// update URLs work; file:// enables offline or side-loaded updates. The bundle store lives at <data_dir>/ota/, outside the swappable payload, and the app's own KV/SQLite data is likewise kept separate - so an update never touches user data.

The update flow#

The control plane is two endpoints:

  • GET /__bext-lite/version (open) - reports version (content), runtime (native), otaEnabled, and the update URL.
  • POST /__bext-lite/update (loopback-only) - checks for and applies an update.

Mutations are loopback-only so a misconfigured 0.0.0.0 bind cannot expose remote update-triggering. An app's own UI offers "Check for updates" with a simple call:

const result = await fetch("/__bext-lite/update", { method: "POST" }).then((r) => r.json());
// { updated: true, from: "1.3.0", to: "1.4.0", notes: "..." }

Optionally check on launch by setting BEXT_LITE_UPDATE_ON_LAUNCH=1.

Under the hood the apply path is fail-safe at every step:

  1. Fetch release.json. If its version equals the installed version, stop (up to date).
  2. Anti-rollback. Refuse any release that is not strictly newer than the installed version. A valid signature proves authenticity, not freshness - an attacker or a stale mirror could otherwise replay a genuinely-signed older release to downgrade the app to a version with known holes. Checked before download, so a rejected downgrade costs no bandwidth.
  3. Size cap. Download the archive with a byte cap equal to the manifest's declared size, aborting if the body exceeds it - a hostile or broken release server cannot stream gigabytes into memory before the checks run.
  4. Verify. Check exact size, then sha256, then the ed25519 signature. Any failure rejects the bundle.
  5. runtimeAbi pin. Even a correctly-signed bundle is refused if its manifest.json declares a different runtime ABI than this build implements - authenticity is not host-contract compatibility.
  6. Atomic swap. Extract to a temp dir, confirm it is a real dist-lite archive, rename it into place, and only then flip the current pointer. A failed or tampered download never becomes current.

Hot swap, no restart#

The server holds the live app behind an RwLock<Arc<AppState>>. Applying an update loads the new bundle and swaps the pointer: in-flight requests finish on the old app, new requests get the new one. No process restart, no dropped connections.

The bridge between the tiers#

A content bundle's release.json may carry minRuntime - the lowest native runtime version it needs. If the installed shell is too old, Tier 1 declines and POST /__bext-lite/update returns:

409  { "nativeUpdateRequired": true, "requiredRuntime": "1.5.0", "haveRuntime": "1.2.0" }

That is the app's cue to run a Tier-2 native update first. This is the guard that keeps the two tiers safe: a content bundle never lands on a runtime missing the bridges or polyfills it depends on.

Publish it with --min-runtime:

bext-lite publish ./dist-lite --key <priv> --version 2.0.0 --min-runtime 1.5.0 --out ./release

Tier 2 - native shell updates#

On desktop, the bext-lite-desktop Tauri shell carries tauri-plugin-updater, opt-in via BEXT_LITE_NATIVE_UPDATES=1: it checks a signed latest.json endpoint, downloads and installs the new binary, and restarts. Use it only when the native layer changes; prefer Tier 1 for everything else.

On mobile, native binary updates go through the App Store or Play Store. Tier 1 content OTA still applies on-device and remains the right channel for day-to-day updates.

The App Store boundary#

Warning

On iOS, Tier-1 content OTA is fine for content and bug fixes but must respect App Store Guideline 2.5.2: an update must not download executable code that changes the app's advertised purpose. Keep OTA scoped to the PRISM payload (HTML, JS, island data - interpreted by the WebView and a jitless QuickJS, the same category as CodePush/Expo). Do not ship a Tier-1 update that adds a feature the reviewed binary could not do. Native runtime changes go through the store as a Tier-2 release.

Because QuickJS is a pure interpreter with no JIT and no downloaded native code, this reading is the friendliest to 2.5.2. Document the boundary in your publish flow so an operator cannot accidentally ship a review-triggering change as content OTA.