// The OTA contract, against the REAL pgh-runtime — not a mock of it.
//
// ⛔ WHY THIS FILE EXISTS. lib/ota.js was written against pgh-runtime's documented interface and had
// never once executed: `pgh-runtime` is `--defer`red in pack:worklet and absent from dependencies, so its
// dynamic import always failed and `startOta` returned null after logging "updates disabled". The code
// underneath was therefore free to be wrong, and it was — in three separate ways, each pinned by a
// control below. Every test here drives pgh-runtime's own `installTree`, so the thing being verified is
// the contract with the library, not our idea of it.
import test from 'brittle'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import crypto from 'hypercore-crypto'
import { computeTreeHash } from 'hyper-overlay/tree'
import { installTree } from 'pgh-runtime'
import { treeFor, bundleFetcher, bundleStore, pickNewest, chooseBundle } from '../lib/ota.js'
const BUNDLE = Buffer.from('#!/bare\n// pretend this is 19MB of worklet\n')
let tmpSeq = 0
function tmpdir (name) {
// A counter, not process.pid: `process` is not a bare global, and Date.now()/Math.random() would make
// the suite non-reproducible for no benefit — each test wants its own empty directory, nothing more.
const dir = path.join(os.tmpdir(), 'favela-ota-' + name + '-' + (++tmpSeq))
fs.rmSync(dir, { recursive: true, force: true })
fs.mkdirSync(dir, { recursive: true })
return dir
}
// A stand-in forge: the two routes lib/ota.js talks to, backed by memory.
function fakeForge ({ versions, files }) {
return async function doFetch (url, opts) {
// release-index, the ANONYMOUS discovery method — the one a phone can actually call. Shaped like the
// real thing: hyper-svc wraps HTTP /rpc/ responses in { result }, and every artifact is listed
// per-version, which is what lets the client skip a release whose files did not all replicate.
if (url.endsWith('/rpc/release-index')) {
const out = []
for (const v of versions) {
for (const n of (v.names || ['tree.json', 'worklet.bundle'])) {
out.push({ path: `favela-android/versions/${v.version}/${n}`, repo: 'favela-android', version: v.version, mtimeMs: v.builtAt })
}
}
return { ok: true, async json () { return { result: { files: out } } } }
}
const hit = Object.keys(files).find((k) => url.endsWith(k))
if (!hit) return { ok: false, status: 404 }
const body = files[hit]
return {
ok: true,
async json () { return JSON.parse(Buffer.from(body).toString('utf8')) },
async arrayBuffer () { return body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength) }
}
}
}
test('CONTROL: the old fetcher could never have downloaded anything', async (t) => {
// What lib/ota.js used to return from getTree, verbatim: an empty tree for every hash. pgh-runtime
// hashes the entries it is given and rejects the tree when they do not reproduce the root hash — that
// guard exists precisely so a peer cannot answer "I don't have it" with an empty dir. So the old code
// could not have fetched a bundle even with the dependency present and a treeHash to ask for: it dies
// one call earlier, every time.
const old = { async getTree () { return { entries: [], size: 0 } }, async getContent () { return new Uint8Array() } }
const store = { init () {}, hasTree () { return false }, async materialize () { t.fail('must not reach materialize') } }
const realHash = computeTreeHash(treeFor(BUNDLE).entries)
await t.exception(installTree(store, realHash, old), /root tree-hash mismatch|EHASHMISMATCH/)
})
test('CONTROL: newest-by-position picks the OLDEST release from this forge', (t) => {
// The old getLatestRelease took versions[versions.length - 1], commented "newest last by publish
// order". The forge lists NEWEST FIRST (verified against `pgh deploy log favela-android`), so that
// line would have pinned a phone to the oldest build on the server and called it an update.
const versions = [
{ version: 'master-6ada0eb', builtAt: 3000 },
{ version: 'master-d233237', builtAt: 2000 },
{ version: 'master-c73eab7', builtAt: 1000 }
]
t.is(versions[versions.length - 1].version, 'master-c73eab7', 'the old rule: oldest')
t.is(pickNewest(versions).version, 'master-6ada0eb', 'by builtAt, not by position')
t.is(pickNewest([...versions].reverse()).version, 'master-6ada0eb', 'and order-independent')
})
test('a one-file tree round-trips through the real installTree', async (t) => {
const dir = tmpdir('roundtrip')
const { entries, treeHash, contentHash } = treeFor(BUNDLE)
const version = 'master-6ada0eb'
const doFetch = fakeForge({
versions: [{ version, builtAt: 3000 }],
files: {
[version + '/tree.json']: Buffer.from(JSON.stringify({ treeHash, entries })),
[version + '/worklet.bundle']: BUNDLE
}
})
const fetcher = bundleFetcher({ base: 'https://forge/git', repo: 'favela-android', doFetch })
const latest = await fetcher.getLatestRelease('favela-android')
t.is(latest.version, version)
t.is(latest.treeHash, treeHash, 'the version advertises the tree hash it will be verified against')
const store = bundleStore({ dir })
const r = await installTree(store, latest.treeHash, fetcher)
t.is(r.materialized, 1)
const staged = store.pathFor(treeHash)
t.ok(staged, 'the store can name the bundle it staged')
t.alike(fs.readFileSync(staged), BUNDLE, 'byte-identical to what the forge served')
t.is(crypto.data(fs.readFileSync(staged)).toString('hex'), contentHash)
})
test('bytes that do not hash to the advertised content hash are REFUSED', async (t) => {
// The forge is HTTPS behind a load balancer and releases are unsigned, so the only thing standing
// between a phone and someone else's bundle is this check. A bundle becomes the code that holds the
// DID signing key — "it downloaded fine" is not a security property.
const dir = tmpdir('tamper')
const { entries, treeHash } = treeFor(BUNDLE)
const version = 'master-6ada0eb'
const doFetch = fakeForge({
versions: [{ version, builtAt: 1 }],
files: {
[version + '/tree.json']: Buffer.from(JSON.stringify({ treeHash, entries })),
[version + '/worklet.bundle']: Buffer.from('#!/bare\n// somebody else’s worklet\n')
}
})
const fetcher = bundleFetcher({ base: 'https://forge/git', repo: 'favela-android', doFetch })
const store = bundleStore({ dir })
await t.exception(installTree(store, treeHash, fetcher), /content hash mismatch/)
t.absent(store.hasTree(treeHash), 'and nothing is left staged for the next boot to pick up')
})
test('a tree.json whose entries do not reproduce its own treeHash is REFUSED', async (t) => {
const dir = tmpdir('badtree')
const { entries, treeHash } = treeFor(BUNDLE)
const lying = treeFor(Buffer.from('different bundle')).entries
const version = 'master-6ada0eb'
const doFetch = fakeForge({
versions: [{ version, builtAt: 1 }],
files: {
[version + '/tree.json']: Buffer.from(JSON.stringify({ treeHash, entries: lying })),
[version + '/worklet.bundle']: BUNDLE
}
})
const fetcher = bundleFetcher({ base: 'https://forge/git', repo: 'favela-android', doFetch })
await t.exception(installTree(bundleStore({ dir }), treeHash, fetcher), /root tree-hash mismatch/)
})
test('hasTree is only true when the BYTES are there, not just the marker', async (t) => {
// The failure this repo has been living with all week, in miniature: an index that advertises a
// version whose bytes never arrived. A marker written before the file is the same bug one layer down,
// so the marker is written LAST and hasTree checks the file too.
const dir = tmpdir('marker')
const { entries, treeHash } = treeFor(BUNDLE)
const version = 'master-6ada0eb'
const doFetch = fakeForge({
versions: [{ version, builtAt: 1 }],
files: {
[version + '/tree.json']: Buffer.from(JSON.stringify({ treeHash, entries })),
[version + '/worklet.bundle']: BUNDLE
}
})
const store = bundleStore({ dir })
t.absent(store.hasTree(treeHash), 'nothing staged yet')
await installTree(store, treeHash, bundleFetcher({ base: 'https://forge/git', repo: 'favela-android', doFetch }))
t.ok(store.hasTree(treeHash), 'staged')
fs.unlinkSync(store.pathFor(treeHash))
t.absent(store.hasTree(treeHash), 'marker without bytes is NOT installed')
})
test('a second install of the same tree is a no-op', async (t) => {
const dir = tmpdir('dedup')
const { entries, treeHash } = treeFor(BUNDLE)
const version = 'master-6ada0eb'
let contentFetches = 0
const base = fakeForge({
versions: [{ version, builtAt: 1 }],
files: {
[version + '/tree.json']: Buffer.from(JSON.stringify({ treeHash, entries })),
[version + '/worklet.bundle']: BUNDLE
}
})
const doFetch = async (url, opts) => { if (url.endsWith('.bundle')) contentFetches++; return base(url, opts) }
const fetcher = bundleFetcher({ base: 'https://forge/git', repo: 'favela-android', doFetch })
const store = bundleStore({ dir })
await installTree(store, treeHash, fetcher)
const again = await installTree(store, treeHash, fetcher)
t.is(again.skipped, 1)
t.is(contentFetches, 1, 'a phone does not re-download 19MB it already has')
})
// ── which bundle actually BOOTS ───────────────────────────────────────────────────────────────────
//
// ⛔ A STAGED BUNDLE NOTHING RUNS IS NOT AN UPDATE. Downloading and verifying is the easy half; the half
// that can brick a phone is choosing to run the new code. There is no adb on a user's handset and no UI
// left if the node fails to boot, so the ONLY safe shape is a watchdog: record the intent before
// starting, clear it once the node reports ready, and treat a marker that survived a boot as proof that
// bundle does not work.
//
// The failure this must not have: a bad bundle that is retried forever, each boot dying the same way.
test('with nothing staged, the APK asset boots', (t) => {
const c = chooseBundle({ current: null, pending: null, failed: [] })
t.is(c.use, 'asset')
})
test('a staged bundle is used, and the intent is recorded BEFORE it runs', (t) => {
const c = chooseBundle({ current: { treeHash: 'aaa', file: 'aaa.bundle' }, pending: null, failed: [] })
t.is(c.use, 'staged')
t.is(c.hash, 'aaa')
t.ok(c.markPending, 'the caller is told to record the attempt before starting')
})
test('a pending marker that survived a boot condemns that bundle', (t) => {
// Written last boot, never cleared ⇒ the node never reached ready on it.
const c = chooseBundle({ current: { treeHash: 'aaa', file: 'aaa.bundle' }, pending: { treeHash: 'aaa' }, failed: [] })
t.is(c.use, 'asset', 'fall back to the APK, which is known to work')
t.is(c.condemn, 'aaa', 'and remember which bundle did it')
})
test('a condemned bundle is never retried, however many times it is offered', (t) => {
const c = chooseBundle({ current: { treeHash: 'aaa', file: 'aaa.bundle' }, pending: null, failed: ['aaa'] })
t.is(c.use, 'asset', 'a bundle that failed once is not tried again')
t.absent(c.condemn, 'and it is not condemned twice')
})
test('a NEWER bundle is tried even after an older one failed', (t) => {
// The fix for a bad release is the next release. Refusing all staged bundles after one failure would
// make the phone permanently un-updatable by a single bad build.
const c = chooseBundle({ current: { treeHash: 'bbb', file: 'bbb.bundle' }, pending: null, failed: ['aaa'] })
t.is(c.use, 'staged')
t.is(c.hash, 'bbb')
})
test('a pending marker for a DIFFERENT bundle does not condemn the current one', (t) => {
// Staged 'bbb' while 'aaa' was mid-attempt: 'aaa' is what failed, not 'bbb'.
const c = chooseBundle({ current: { treeHash: 'bbb', file: 'bbb.bundle' }, pending: { treeHash: 'aaa' }, failed: [] })
t.is(c.condemn, 'aaa', 'the one that was actually being attempted is condemned')
t.is(c.use, 'staged', 'and the new one still gets its chance')
t.is(c.hash, 'bbb')
})
test('a malformed current.json is ignored rather than thrown on', (t) => {
t.is(chooseBundle({ current: {}, pending: null, failed: [] }).use, 'asset')
t.is(chooseBundle({ current: { treeHash: 'aaa' }, pending: null, failed: [] }).use, 'asset', 'no file, no boot')
})
test('an INCOMPLETE release is skipped for the newest complete one', async (t) => {
// Measured on the forge 2026-08-18: master-5d6d7e4 served build.log, manifest.json and the APK while
// tree.json and worklet.bundle never replicated. Taking "newest" blindly pins a phone to a version it
// can never install and retries it forever, so the rule is newest COMPLETE.
const { entries, treeHash } = treeFor(BUNDLE)
const good = 'master-53b10d2'
const partial = 'master-5d6d7e4'
const doFetch = fakeForge({
versions: [
{ version: good, builtAt: 1000, names: ['tree.json', 'worklet.bundle'] },
{ version: partial, builtAt: 5000, names: ['build.log', 'manifest.json', 'favela-android.apk'] }
],
files: {
[good + '/tree.json']: Buffer.from(JSON.stringify({ treeHash, entries })),
[good + '/worklet.bundle']: BUNDLE
}
})
const latest = await bundleFetcher({ base: 'https://forge/git', repo: 'favela-android', doFetch }).getLatestRelease('favela-android')
t.ok(latest, 'a complete release is found')
t.is(latest.version, good, 'the NEWER but incomplete release is skipped')
})
test('no complete release at all is null, not a crash', async (t) => {
const doFetch = fakeForge({
versions: [{ version: 'master-5d6d7e4', builtAt: 5000, names: ['build.log', 'favela-android.apk'] }],
files: {}
})
const latest = await bundleFetcher({ base: 'https://forge/git', repo: 'favela-android', doFetch }).getLatestRelease('favela-android')
t.is(latest, null)
})
// ⛔ AN APK INSTALL MUST SUPERSEDE AN OLDER STAGED BUNDLE. Staged used to win unconditionally, and tree
// hashes carry no ordering — so a freshly installed APK ran the JS staged BEFORE it until the next OTA
// pass happened to catch up. Seen twice on a handset in one afternoon while shipping (hyper-social #53):
// the new shell booted, the new bundle sat in assets, and the old staged one ran anyway.
//
// Build times are the only comparable thing the two sides share — CI stamps the APK's bundle, lib/ota.js
// records the release's build time in the staged marker.
test('an APK built after the staged bundle wins', (t) => {
const staged = { treeHash: 'a'.repeat(64), file: 'a.bundle', builtAt: 1000 }
t.is(chooseBundle({ current: staged, assetBuiltAt: 500 }).use, 'staged', 'a staged bundle newer than the APK still runs')
t.is(chooseBundle({ current: staged, assetBuiltAt: 5000 }).use, 'asset', 'an APK built later supersedes it')
t.is(chooseBundle({ current: staged, assetBuiltAt: 1000 }).use, 'staged', 'a tie keeps the staged bundle — no churn')
})
// ⛔ THE SECOND CASE HERE WAS REVERSED ON 2026-08-25, AND IT WAS A REAL BUG, NOT A PREFERENCE.
//
// "Never invent an ordering" is right when NEITHER side can be dated. It was wrong when only the STAGED
// side could not: the HTTPS fetcher's getLatestRelease returned { name, version, treeHash } and no
// builtAt, so every bundle it staged was undatable and therefore permanently unbeatable. A handset took
// four APK installs in a row, each carrying fixes, and ran 0.1.6's JS through all of them — the install
// succeeds, the version looks right, and the code does not run.
//
// An APK install is a physical, deliberate act by someone holding the device. A staged bundle that
// cannot say when it was built cannot be shown to be newer than that, so the tie goes to the APK. When
// the APK cannot be dated either, nothing is orderable and the historic behaviour genuinely does stand.
test('an undatable staged bundle loses to a stamped APK, but not to an unstamped one', (t) => {
const staged = { treeHash: 'a'.repeat(64), file: 'a.bundle', builtAt: 1000 }
t.is(chooseBundle({ current: staged }).use, 'staged', 'no APK stamp => staged wins; nothing is orderable')
t.is(chooseBundle({ current: { treeHash: 'b'.repeat(64), file: 'b.bundle' }, assetBuiltAt: 5000 }).use, 'asset',
'no staged builtAt but a stamped APK => the APK is the more recent evidence')
})
// The watchdog outranks the timestamps: a bundle that failed to boot is never resurrected by being newer.
test('a condemned bundle is still refused', (t) => {
const staged = { treeHash: 'a'.repeat(64), file: 'a.bundle', builtAt: 9999 }
t.is(chooseBundle({ current: staged, assetBuiltAt: 1, failed: ['a'.repeat(64)] }).use, 'asset',
'condemned wins over any build-time comparison')
})