OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android / lib / ota.js

421 lines · javascript ref:
// android-ota — keep the JS current on a phone, so the APK only has to change when native code does.
//
// THE SPLIT THIS EXISTS TO CREATE. An APK carries two very different things: ~77 MB of native libraries
// that change rarely (bare-kit + our addons), and a worklet bundle that changes constantly. Today proved
// the cost of not having this: a favela-web fix reached the handset only by rebuilding a 153 MB APK
// (150s of CI) and sideloading it, because favela-android vendors favela-web at build time.
//
// ⛔ WHAT WAS HERE BEFORE HAD NEVER RUN. pgh-runtime was `--defer`red out of the bundle and absent from
// dependencies, so `startOta`'s dynamic import always failed and this file returned null after logging
// "updates disabled". The code underneath was free to be wrong, and was: `getTree` returned
// `{entries: []}` for every hash, and pgh-runtime's installTree hashes the entries it is handed and
// rejects the tree unless they reproduce the root hash — a guard that exists so a peer cannot answer "I
// don't have it" with an empty directory. So it could not have downloaded a bundle even with the
// dependency present. It also took `versions[versions.length - 1]` as "newest", and this forge lists
// newest FIRST, so it would have pinned a phone to the oldest build on the server and called it current.
//
// ⛔ THE TWO "RELEASE" SYSTEMS ARE NOT INTERCHANGEABLE:
//   - pgh-sidecar's ReleaseRegistry: an Autobase of METADATA. Replicates P2P, never produces a file.
//   - pear-git-v3's ReleaseManager: real files on disk, served over HTTPS at
//     /git/releases/<repo>/<version>/<file>. This is the one that can hand a phone bytes.
// A phone needs BYTES, so this targets the second.
//
// ⚠ Artifacts are per-daemon. 7rnx.net/git is load-balanced and a release built on one backend is not
// automatically complete on the other (measured 2026-08-18: a 153 MB APK never reached the second
// backend while its build.log and manifest did). So `base` must point at a backend that actually holds
// the bytes, and every fetch is hash-verified rather than trusted because of where it came from.
import fs from 'bare-fs'
import path from 'bare-path'
import crypto from 'hypercore-crypto'
import { computeTreeHash, KIND_FILE } from 'hyper-overlay/tree'

const DEFAULT_BASE = 'https://7rnx.net/git'
const DEFAULT_REPO = 'favela-android'
// The publisher's release registry — the hex in every hyper-fleeky app's `pgh.upgrade`
// (hyper-proto-peer, atproto-bridge, hypernote, favela-web, pgh-sidecar all carry this exact value).
//
// ⛔ A CONSTANT, AND DELIBERATELY NOT A LIST. This is a publisher IDENTITY, not a set of hosts: the
// serving topic comes from the resolved release's own serverKey, so adding, moving or rotating a backend
// changes nothing here. A hand-maintained list of hosts would be the frontedDids anti-pattern
// (hyper-social #54 §8) on the one component that cannot be fixed remotely.
const DEFAULT_REGISTRY = '1dd36bdfa9cb7f7d84adbd07f017e7200f7f90a3a546cd0761fac2a1f8a13e86'
const DEFAULT_INTERVAL = 6 * 60 * 60 * 1000 // 6h — a phone is not a server; do not poll it awake
const BUNDLE_NAME = 'worklet.bundle'

const hashOf = (bytes) => crypto.data(Buffer.from(bytes)).toString('hex')

// The running bundle's hash, if the environment happens to say. Guarded because `process` is a shim in
// the worklet and absent everywhere else; an OTA client is not the place to discover that.
function envBundleHash () {
  try { return (typeof process !== 'undefined' && process.env) ? process.env.FAVELA_BUNDLE_HASH : null } catch { return null }
}

/**
 * The one-file tree for a bundle: what CI publishes as tree.json and what the phone verifies against.
 *
 * Deliberately built with hyper-overlay's own encoder rather than by hand — pgh-runtime verifies with
 * `computeTreeHash`, so producing the hash any other way would be a second definition of the format that
 * agrees until it doesn't.
 */
export function treeFor (bytes, name = BUNDLE_NAME) {
  const contentHash = hashOf(bytes)
  const entries = [{ kind: KIND_FILE, exec: 0, name, childHash: contentHash }]
  return { entries, treeHash: computeTreeHash(entries), contentHash, size: bytes.length }
}

/**
 * Newest release by BUILD TIME, not by position in the list.
 *
 * The old code took the last element and commented "newest last by publish order". `pgh deploy log`
 * returns newest FIRST, so that read the oldest build on the server. Ordering the caller cannot see is
 * not a contract; the timestamp is.
 */
export function pickNewest (versions) {
  const list = (Array.isArray(versions) ? versions : []).filter(Boolean)
  if (!list.length) return null
  return list.reduce((a, b) => ((Number(b.builtAt) || 0) > (Number(a.builtAt) || 0) ? b : a))
}

/**
 * A pgh-runtime fetcher over the forge's HTTPS release routes.
 *
 * Shape required by pgh-runtime: { getLatestRelease(name), getTree(hash), getContent(hash) }.
 */
export function bundleFetcher ({ base = DEFAULT_BASE, repo = DEFAULT_REPO, doFetch }) {
  // treeHash -> { version, entries }, and contentHash -> { version, name }. Populated when a tree.json is
  // read, because installTree asks for a tree and then for content BY HASH, with no version in hand.
  const trees = new Map()
  const contents = new Map()

  const releaseUrl = (version, file) => `${base}/releases/${repo}/${version}/${file}`

  async function readTree (version) {
    const res = await doFetch(releaseUrl(version, 'tree.json'))
    if (!res || res.ok === false) throw new Error(`no tree.json for ${version}`)
    const body = await res.json()
    const entries = (body && body.entries) || []
    // Trust the ENTRIES, not the advertised hash: recompute it. installTree checks this too, but a
    // fetcher that propagated a hash it never verified would hand the guard its own answer.
    const treeHash = computeTreeHash(entries)
    trees.set(treeHash, { version, entries })
    for (const e of entries) contents.set(e.childHash, { version, name: e.name })
    return { treeHash, entries }
  }

  // Named, because getTree's fallback calls back into getLatestRelease — one discovery path, not two.
  const api = {
    // ⛔ `release-index`, NOT `list-repo-releases`. The latter is behind the anonymous-RPC gate (pear-git
    // org #12: the whole /rpc/ surface was world-callable until it became opt-in), so a phone — anonymous
    // by construction — gets 403 forever. Measured on the handset: the check SUCCEEDS having found
    // nothing, prints no error, and is indistinguishable from being up to date.
    //
    // release-index is already in ANONYMOUS_METHODS, and for the same use: "Release discovery for a
    // ReleasePuller… The handler filters its response by repoAccess for anonymous callers, so a private
    // repo's versions and hashes stay hidden." A phone updating itself is a puller. Opening a second
    // method would have been inventing a path around one that already exists.
    async getLatestRelease (name) {
      const res = await doFetch(`${base}/rpc/release-index`, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: '{}'
      })
      if (!res || res.ok === false) return null
      const body = await res.json()
      // hyper-svc wraps HTTP /rpc/ responses in { result }. The old code read body.versions and would
      // have found undefined even if the gate had let it through.
      const index = (body && body.result) || body || {}

      // Group this repo's files by version. The index carries every artifact, so it can answer the
      // question that matters: which versions are actually COMPLETE?
      const byVersion = new Map()
      for (const f of index.files || []) {
        if (!f || f.repo !== repo || !f.version) continue
        if (!byVersion.has(f.version)) byVersion.set(f.version, { version: f.version, builtAt: 0, names: new Set() })
        const v = byVersion.get(f.version)
        v.names.add(String(f.path || '').split('/').pop())
        if (Number(f.mtimeMs) > v.builtAt) v.builtAt = Number(f.mtimeMs)
      }

      // ⛔ SKIP INCOMPLETE RELEASES. Artifacts replicate per-file between forge backends and partial
      // arrivals are real — measured 2026-08-18: master-5d6d7e4 served build.log, manifest.json and the
      // APK while tree.json and worklet.bundle never arrived. Taking "newest" blindly would pin the phone
      // to a version it can never install, and retry it forever. Newest COMPLETE is the honest rule.
      const complete = [...byVersion.values()].filter((v) => v.names.has('tree.json') && v.names.has(BUNDLE_NAME))
      const newest = pickNewest(complete)
      if (!newest) return null

      // A release with no readable tree is not an error worth crashing a boot over.
      let tree
      try { tree = await readTree(newest.version) } catch { return null }
      return { name, version: newest.version, treeHash: tree.treeHash }
    },

    // ⛔ FETCH, DO NOT JUDGE. This returns whatever the release declares and lets installTree's
    // computeTreeHash guard accept or reject it. An earlier cut gated here too and had to be pulled: a
    // fetcher that verifies is a SECOND definition of the check, and the two agree right up until they
    // do not — and it made the fetcher order-dependent, answering only for a tree getLatestRelease had
    // pre-warmed. One guard, in the layer that owns it.
    async getTree (rootHash) {
      const hit = trees.get(rootHash)
      if (hit) return { entries: hit.entries, size: hit.entries.length }
      // Not seen yet — the caller went straight to installTree. Read the newest release's tree and hand
      // it over; if it is not the one asked for, installTree says so in the words an operator needs.
      // Same discovery path as getLatestRelease — the gated route would 403 here too.
      const latest = await api.getLatestRelease(repo)
      if (!latest) throw new Error('no complete release for ' + repo)
      const again = trees.get(latest.treeHash)
      if (!again) throw new Error('no tree for ' + latest.version)
      return { entries: again.entries, size: again.entries.length }
    },

    async getContent (contentHash) {
      const where = contents.get(contentHash)
      if (!where) throw new Error('unknown content ' + String(contentHash).slice(0, 12) + '…')
      const res = await doFetch(releaseUrl(where.version, where.name))
      if (!res || res.ok === false) throw new Error('fetch failed: ' + where.name)
      const bytes = new Uint8Array(await res.arrayBuffer())
      // ⛔ THE ONLY THING PROTECTING THIS PHONE. Releases are unsigned (`signed: false` in every publish)
      // and the forge is load-balanced HTTPS, so "it downloaded" says nothing about WHAT downloaded. The
      // bundle becomes the code that holds the DID signing key. Verify, or do not install.
      const got = hashOf(bytes)
      if (got !== contentHash) {
        throw new Error(`content hash mismatch for ${where.name}: expected ${contentHash.slice(0, 12)}…, got ${got.slice(0, 12)}…`)
      }
      return bytes
    }
  }

  return api
}

/**
 * Store-only: write the bundle, and let the NEXT start pick it up.
 *
 * Applying a new bundle to a running node would mean swapping the code under itself. A phone restarts
 * constantly anyway, so "active on next start" costs nothing and avoids a class of live-reload bugs.
 *
 * ⛔ NO MATERIALIZER. pgh's node materializer HARDLINKS, and SELinux denies `link` device-wide on
 * Android — which is what made a 269-dep closure balloon to ~5.7 GB on celltron. One file, written
 * directly, is the whole of what a bundle needs.
 */
export function bundleStore ({ dir }) {
  const markerPath = (treeHash) => path.join(dir, treeHash + '.json')
  // The release currently being installed. pgh-runtime's installTree does not pass the release down to
  // the store, but it emits `updating` with it immediately beforehand — so the caller notes it here and
  // materialize can stamp the staged marker with a comparable build time. Without that the shell has no
  // way to tell a staged bundle from the one inside a freshly installed APK.
  let pendingRelease = null
  const readMarker = (treeHash) => {
    try { return JSON.parse(fs.readFileSync(markerPath(treeHash), 'utf8')) } catch { return null }
  }

  return {
    init () { fs.mkdirSync(dir, { recursive: true }) },

    /** Told by startOta from the `updating` event, just before installTree runs. */
    noteRelease (r) { pendingRelease = r || null },

    /**
     * ⛔ THE BYTES, NOT JUST THE MARKER. A marker written before the file it names is the same bug the
     * forge has been living with all week — an index advertising a release whose artifact never arrived.
     * So the marker is written LAST and this checks the file too.
     */
    hasTree (treeHash) {
      const m = readMarker(treeHash)
      if (!m || !m.file) return false
      try { return fs.statSync(path.join(dir, m.file)).size > 0 } catch { return false }
    },

    /** Where the staged bundle for this tree lives, or null. */
    pathFor (treeHash) {
      const m = readMarker(treeHash)
      return m && m.file ? path.join(dir, m.file) : null
    },

    async materialize (rootHash, entries, { fetchContent } = {}) {
      // ⛔ RECORD A BUILD TIME, WHATEVER THE RELEASE CALLS IT. chooseBundle compares this against the
      // APK's stamp, and a staged bundle with no time is one an APK install cannot supersede — the
      // failure that left a handset running 0.1.6's JS through four installs on 2026-08-25.
      //
      // The two release shapes name it differently and neither is wrong: a forge release carries
      // `builtAt`, the pgh ReleaseRegistry carries an ISO `publishedAt`. Reading only one is how the
      // swarm fetcher would have inherited the very bug it was written to replace.
      const builtAt = (pendingRelease && (
        (Number(pendingRelease.builtAt) || 0) ||
        (Date.parse(pendingRelease.publishedAt || '') || 0)
      )) || null
      this.init()
      // ⛔ BY NAME, NEVER BY POSITION. This used to write EVERY file entry and keep whichever landed
      // last. That was safe only while a tree had exactly one entry, and the ci.yaml note said what
      // happens otherwise: "a two-entry tree stages a 154 MB APK as the worklet bundle. The OTA client
      // on a handset is exactly what cannot be fixed remotely."
      //
      // It stopped being hypothetical when CI started publishing to the release registry: `pgh publish`
      // requires a package.json, so the published tree is { package.json, worklet.bundle } and the winner
      // depended on an ordering nothing here controls or asserts. Selecting by name also stops the phone
      // downloading content it will discard — the old loop fetched every entry before dropping all but one.
      const want = (entries || []).find((e) => e && e.kind === KIND_FILE && e.name === BUNDLE_NAME)
      if (!want) {
        const names = (entries || []).filter((e) => e && e.kind === KIND_FILE).map((e) => e.name)
        throw new Error('tree contains no ' + BUNDLE_NAME + ' to stage (files: ' + (names.join(', ') || 'none') + ')')
      }
      const bytes = await fetchContent(want.childHash)
      // Write to .part and rename: a phone is killed constantly, and a truncated bundle that hasTree
      // then trusted would be a node that cannot boot with no way to say why.
      const dest = path.join(dir, want.childHash + '.bundle')
      const part = dest + '.part'
      fs.writeFileSync(part, Buffer.from(bytes))
      fs.renameSync(part, dest)
      const file = want.childHash + '.bundle'
      // `builtAt` is what lets the SHELL compare a staged bundle with the one inside the APK. Without it
      // an APK install loses to whatever was staged before it — chooseBundle prefers staged, and tree
      // hashes carry no ordering, so a phone kept running the older JS until the next OTA pass caught up.
      fs.writeFileSync(markerPath(rootHash), JSON.stringify({ treeHash: rootHash, file, builtAt }, null, 2) + '\n')
      // current.json is how the REACT NATIVE side learns what is staged: it runs before the worklet and
      // cannot read this store's code (bare-fs), only its files. Written last, after the bytes and the
      // marker, so it never advertises something that is not fully on disk.
      fs.writeFileSync(path.join(dir, 'current.json'), JSON.stringify({ treeHash: rootHash, file, builtAt }, null, 2) + '\n')
    }
  }
}

// The boot-time choice lives in its own dependency-free module so the React Native side can import
// it too (this file imports bare-fs, which does not exist there). Re-exported for callers inside the
// worklet, so there is one name for it.
export { chooseBundle } from './choose-bundle.js'

/**
 * THE FETCHER EVERY OTHER hyper-fleeky APP USES — Hyperswarm + hyper-overlay. No HTTPS, no backend.
 *
 * ⛔ WHAT THIS REPLACES, AND WHY IT WAS WRONG. The HTTPS fetcher spoke to https://7rnx.net/git — a
 * LOAD-BALANCED pair of forge daemons — for discovery, tree AND content. pgh-runtime never asked for
 * that: it is transport-agnostic and REQUIRES an injected fetcher, and pgh has shipped the Hyperswarm
 * implementation the whole time. favela-android was the only app not using it, so hyper-overlay was
 * reduced to a hashing library here while every other app used it as the transport it is.
 *
 * The cost was not theoretical. A release lands on the backend that BUILT it and replicates to the other
 * asynchronously; the phone asks the load balancer and gets whichever. Measured 2026-08-25: 0.1.6
 * replicated in ~15s, 0.1.7 had NOT replicated after 600s — the phone correctly reported no update and
 * was stuck. Measured 2026-08-18: a 153 MB APK never reached the second backend at all.
 *
 * TWO PIECES, both already exported by pgh:
 *   makeGetLatestRelease({ registryKeyHex })  replicates the PUBLISHER's registry over Hyperswarm —
 *                                            resolve-release.js path 2, written for exactly this case
 *                                            ("every standalone install"), which a handset is.
 *   NetworkFetcher({ topic: serverKey })      tree + content over hyper-overlay's protocol.
 *
 * MEASURED end-to-end against the real published release before this shipped: resolve 13.2s cold,
 * getTree 26ms, and the 38,135,946-byte worklet bundle in 8.4s.
 *
 * ⛔ RESOLVE ONCE. The 13s is registry replication from cold and it dominates everything else; fetches
 * after it are milliseconds. A phone that re-resolved per wake would pay it every time, so the topic is
 * learned once per boot — the same thing pgh's own runtime-factory does. A publisher that moves to a new
 * serverKey is a restart, not a case worth carrying state for.
 *
 * ANDROID: `preferSidecar: false`. resolve-release tries a pgh-sidecar UDS first because on a normal
 * machine both paths open the same registry corestore and a direct open loses an fd-lock race. A handset
 * has no sidecar and never will, so the probe is pure latency — and SELinux makes pathname sockets their
 * own hazard here.
 */
export async function swarmFetcher ({ registryKeyHex = DEFAULT_REGISTRY, repo = DEFAULT_REPO, timeout = 120000, destDir = null, swarm = null } = {}) {
  const { makeGetLatestRelease } = await import('pgh/resolve-release')
  const { NetworkFetcher } = await import('pgh/network-fetcher')

  const getLatestRelease = makeGetLatestRelease({ registryKeyHex, preferSidecar: false })
  const latest = await getLatestRelease(repo)
  if (!latest || !latest.serverKey) throw new Error('no published release with a serverKey for ' + repo)

  const fetcher = new NetworkFetcher({ topic: latest.serverKey, timeout, destDir, swarm })
  // NetworkFetcher's own getLatestRelease reads a LOCAL registry, which a phone does not have. Its header
  // calls the replacement a follow-on; the follow-on is the resolver above, and this is the same
  // substitution runtime-factory makes for the same reason.
  fetcher.getLatestRelease = getLatestRelease
  await fetcher.ready()
  return { fetcher, latest }
}

/**
 * Start the bundle updater. Returns { stop() } or null when OTA is unavailable.
 *
 * Never throws: the caller boots a node either way.
 */
export async function startOta ({ storage, say = () => {}, repo = DEFAULT_REPO, interval = DEFAULT_INTERVAL, currentTreeHash = null, registryKeyHex = DEFAULT_REGISTRY, swarm = null, fetcher: injectedFetcher = null } = {}) {
  let PghRuntime = null
  try {
    ;({ PghRuntime } = await import('pgh-runtime'))
  } catch {
    say('ota: pgh-runtime not bundled — updates disabled (add the dep to enable)')
    return null
  }

  const dir = path.join(storage, 'bundles')
  const store = bundleStore({ dir })
  store.init()

  // THE FETCHER IS INJECTED, exactly as pgh-runtime injects one into PghRuntime — one transport seam, in
  // the layer that owns it. Production passes nothing and gets the swarm; tests pass a double and can
  // exercise the store/runtime wiring without a DHT, which is the coverage that caught the two-week
  // "OTA never ran" bug and must not be lost with the transport.
  //
  // ⛔ NO HTTPS FALLBACK, DELIBERATELY. A fallback means the phone silently keeps using the load-balanced
  // path whenever the swarm is slow — which is precisely when the swarm is the thing to debug, and
  // precisely the failure HTTPS already has. Two transports is also two sets of failure modes on the one
  // component that cannot be fixed remotely. If this cannot reach a peer, OTA is off for that boot and
  // says so; a sideloaded APK stays the recovery route, as it already is for every native change.
  let fetcher = injectedFetcher
  if (!fetcher) {
    try {
      ;({ fetcher } = await swarmFetcher({ registryKeyHex, repo, destDir: path.join(dir, '.fetch'), swarm }))
    } catch (err) {
      say('ota: no swarm fetcher — updates disabled this boot (' + ((err && err.message) || err) + ')')
      return null
    }
  }

  const runtime = new PghRuntime({
    name: repo,
    // ⛔ NOT `process.env.X` BARE. `process` is not a Bare global — it exists in the worklet only because
    // lib/node.js imports favela-web's bare-globals shim before anything else. So this line worked on the
    // phone and threw ReferenceError everywhere else, including the first test that ever called startOta.
    // A library used from three environments must not read a global it did not import.
    currentTreeHash: currentTreeHash || envBundleHash() || null,
    fetcher,
    store,
    interval
  })

  runtime.on('updating', (r) => { store.noteRelease(r); say('ota: downloading ' + (r && r.version)) })
  runtime.on('updated', (i) => say('ota: staged ' + i.newTreeHash + ' — active on next start'))
  runtime.on('error', (e) => say('ota: ' + ((e && e.message) || e)))

  await runtime.start()
  say('ota: watching ' + repo + ' over the swarm every ' + Math.round(interval / 3600000) + 'h')
  // ⛔ `checkNow` IS EXPOSED. The 6-hour timer is a poll on a phone that is awake for seconds at a time,
  // so the interval is almost never when the user is actually looking — the one moment a check is worth
  // making is when they ask. pgh-runtime already has the public method (checkNow, index.js:107); this
  // just stops throwing the handle away, which is the whole reason the app had no update control.
  // ⛔ CLOSE THE SWARM WE OPENED. stop() used to stop only the poll loop, leaving NetworkFetcher's
  // Hyperswarm node and corestore open for the life of the process. On a server that is untidy; on a
  // phone it is a DHT node and a set of sockets held awake by something the user has switched off. It is
  // the same shape as the hyper-svc UDS leak that reached ~205k sockets and ~14 GB on stortron — a
  // handle nobody closed because the thing that created it was not the thing that owned it.
  //
  // Caught empirically: the real end-to-end probe finished staging in 23.5s and then never exited, and
  // `timeout` killed it at 600s (EXIT=124). A stop() that leaves the process alive is not a stop.
  //
  // OWNERSHIP: only close what we created. An injected fetcher belongs to the caller — a test owns its
  // double's lifecycle, and closing someone else's handle is how you get a use-after-close in the suite
  // that was meant to be catching this.
  const ownsFetcher = !injectedFetcher
  return {
    stop: async () => {
      try { if (runtime.stop) await runtime.stop() } catch { /* stopping must not throw on the way out */ }
      if (ownsFetcher && fetcher && typeof fetcher.close === 'function') {
        try { await fetcher.close() } catch { /* same */ }
      }
    },
    store,
    checkNow: () => runtime.checkNow()
  }
}