OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android commits / 6245afd

discover releases through the route a phone is allowed to call
getLatestRelease called `list-repo-releases`, which is behind the anonymous RPC gate (pear-git org #12, added after an anonymous curl returned org data on prod). A phone is anonymous by construction, so it got 403 on every check — and the failure was silent in the worst way: the check SUCCEEDED having found nothing, printed no error, and was indistinguishable from being up to date. Measured on the handset: `ota: watching …` and nothing else, ever. The fix was not to open a second method. ANONYMOUS_METHODS already contains `release-index`, documented for exactly this: "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. Widening the surface org #12 deliberately closed, to reach data the sanctioned route already serves, would have been inventing a path around a system that already worked. The index turns out to be strictly better than the gated route, because it lists every ARTIFACT per version, not just version names: - 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 — a second latent bug in never-run code. - INCOMPLETE RELEASES ARE NOW SKIPPED. Artifacts replicate per-file between forge backends and partial arrivals are real: master-5d6d7e4 is serving build.log, manifest.json and the APK right now, with tree.json and worklet.bundle absent. Taking "newest" blindly pins a phone to a version it can never install and retries it forever. Newest COMPLETE is the honest rule, and only the index can express it. Verified against the LIVE forge, anonymously, end to end: ota: downloading master-c2abf1c ota: staged e9cc9cb5fc093a03… — active on next start staged bytes 37469445 · hasTree true next boot {"use":"staged","markPending":true} chooseBundle returned "staged" instead of "asset" for the first time. 16/16 unit (two new cases built from the real 5d6d7e4 shape), 3/3 e2e — whose forge now also serves a version that is NEWER by mtime and incomplete. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6245afde7401f39a8a518117b1629440f5308386
by plankatron <fleeky@gmail.com> · 2026-08-18T19:01:54.000Z

Changed files (3) +99 -24

lib/ota.js +46 -19
……@@ -94,22 +94,52 @@ export function bundleFetcher ({ base = DEFAULT_BASE, repo = DEFAULT_REPO, doFet
9494 return { treeHash, entries }
9595 }
9696
97 return {
97 // Named, because getTree's fallback calls back into getLatestRelease — one discovery path, not two.
98 const api = {
99 // ⛔ `release-index`, NOT `list-repo-releases`. The latter is behind the anonymous-RPC gate (pear-git
100 // org #12: the whole /rpc/ surface was world-callable until it became opt-in), so a phone — anonymous
101 // by construction — gets 403 forever. Measured on the handset: the check SUCCEEDS having found
102 // nothing, prints no error, and is indistinguishable from being up to date.
103 //
104 // release-index is already in ANONYMOUS_METHODS, and for the same use: "Release discovery for a
105 // ReleasePuller… The handler filters its response by repoAccess for anonymous callers, so a private
106 // repo's versions and hashes stay hidden." A phone updating itself is a puller. Opening a second
107 // method would have been inventing a path around one that already exists.
98108 async getLatestRelease (name) {
99 const res = await doFetch(`${base}/rpc/list-repo-releases`, {
109 const res = await doFetch(`${base}/rpc/release-index`, {
100110 method: 'POST',
101111 headers: { 'content-type': 'application/json' },
102 body: JSON.stringify({ repo })
112 body: '{}'
103113 })
104114 if (!res || res.ok === false) return null
105115 const body = await res.json()
106 const newest = pickNewest((body && (body.versions || body.releases)) || [])
116 // hyper-svc wraps HTTP /rpc/ responses in { result }. The old code read body.versions and would
117 // have found undefined even if the gate had let it through.
118 const index = (body && body.result) || body || {}
119
120 // Group this repo's files by version. The index carries every artifact, so it can answer the
121 // question that matters: which versions are actually COMPLETE?
122 const byVersion = new Map()
123 for (const f of index.files || []) {
124 if (!f || f.repo !== repo || !f.version) continue
125 if (!byVersion.has(f.version)) byVersion.set(f.version, { version: f.version, builtAt: 0, names: new Set() })
126 const v = byVersion.get(f.version)
127 v.names.add(String(f.path || '').split('/').pop())
128 if (Number(f.mtimeMs) > v.builtAt) v.builtAt = Number(f.mtimeMs)
129 }
130
131 // ⛔ SKIP INCOMPLETE RELEASES. Artifacts replicate per-file between forge backends and partial
132 // arrivals are real — measured 2026-08-18: master-5d6d7e4 served build.log, manifest.json and the
133 // APK while tree.json and worklet.bundle never arrived. Taking "newest" blindly would pin the phone
134 // to a version it can never install, and retry it forever. Newest COMPLETE is the honest rule.
135 const complete = [...byVersion.values()].filter((v) => v.names.has('tree.json') && v.names.has(BUNDLE_NAME))
136 const newest = pickNewest(complete)
107137 if (!newest) return null
108 const version = newest.version || String(newest)
109 // A release with no tree.json is one that predates OTA, not an error worth crashing a boot over.
138
139 // A release with no readable tree is not an error worth crashing a boot over.
110140 let tree
111 try { tree = await readTree(version) } catch { return null }
112 return { name, version, treeHash: tree.treeHash }
141 try { tree = await readTree(newest.version) } catch { return null }
142 return { name, version: newest.version, treeHash: tree.treeHash }
113143 },
114144
115145 // ⛔ FETCH, DO NOT JUDGE. This returns whatever the release declares and lets installTree's
……@@ -122,17 +152,12 @@ export function bundleFetcher ({ base = DEFAULT_BASE, repo = DEFAULT_REPO, doFet
122152 if (hit) return { entries: hit.entries, size: hit.entries.length }
123153 // Not seen yet — the caller went straight to installTree. Read the newest release's tree and hand
124154 // it over; if it is not the one asked for, installTree says so in the words an operator needs.
125 const res = await doFetch(`${base}/rpc/list-repo-releases`, {
126 method: 'POST',
127 headers: { 'content-type': 'application/json' },
128 body: JSON.stringify({ repo })
129 })
130 if (!res || res.ok === false) throw new Error('cannot list releases for ' + repo)
131 const body = await res.json()
132 const newest = pickNewest((body && (body.versions || body.releases)) || [])
133 if (!newest) throw new Error('no releases for ' + repo)
134 const { entries } = await readTree(newest.version || String(newest))
135 return { entries, size: entries.length }
155 // Same discovery path as getLatestRelease — the gated route would 403 here too.
156 const latest = await api.getLatestRelease(repo)
157 if (!latest) throw new Error('no complete release for ' + repo)
158 const again = trees.get(latest.treeHash)
159 if (!again) throw new Error('no tree for ' + latest.version)
160 return { entries: again.entries, size: again.entries.length }
136161 },
137162
138163 async getContent (contentHash) {
……@@ -151,6 +176,8 @@ export function bundleFetcher ({ base = DEFAULT_BASE, repo = DEFAULT_REPO, doFet
151176 return bytes
152177 }
153178 }
179
180 return api
154181}
155182
156183/**
test/ota-e2e.test.mjs +9 -3
……@@ -39,10 +39,16 @@ function tmp (name) {
3939// A forge with the two routes and nothing else — the same shape pear-git-v3 serves.
4040async function forge ({ repo, version, bundle, tree }) {
4141 const server = http.createServer((req, res) => {
42 if (req.method === 'POST' && req.url.endsWith('/rpc/list-repo-releases')) {
43 const body = JSON.stringify({ versions: [{ version, builtAt: 2000 }, { version: 'older', builtAt: 1000 }] })
42 if (req.method === 'POST' && req.url.endsWith('/rpc/release-index')) {
43 // The real shape: { result: { files: [...] } }, one entry per artifact, so the client can tell a
44 // complete release from a half-replicated one.
45 const files = [
46 { path: `${repo}/versions/${version}/tree.json`, repo, version, mtimeMs: 2000 },
47 { path: `${repo}/versions/${version}/worklet.bundle`, repo, version, mtimeMs: 2000 },
48 { path: `${repo}/versions/older/favela-android.apk`, repo, version: 'older', mtimeMs: 9000 }
49 ]
4450 res.writeHead(200, { 'content-type': 'application/json' })
45 return res.end(body)
51 return res.end(JSON.stringify({ result: { files } }))
4652 }
4753 if (req.url.endsWith(`/releases/${repo}/${version}/tree.json`)) {
4854 res.writeHead(200, { 'content-type': 'application/json' })
test/ota.test.mjs +44 -2
……@@ -30,8 +30,17 @@ function tmpdir (name) {
3030// A stand-in forge: the two routes lib/ota.js talks to, backed by memory.
3131function fakeForge ({ versions, files }) {
3232 return async function doFetch (url, opts) {
33 if (url.endsWith('/rpc/list-repo-releases')) {
34 return { ok: true, async json () { return { versions } } }
33 // release-index, the ANONYMOUS discovery method — the one a phone can actually call. Shaped like the
34 // real thing: hyper-svc wraps HTTP /rpc/ responses in { result }, and every artifact is listed
35 // per-version, which is what lets the client skip a release whose files did not all replicate.
36 if (url.endsWith('/rpc/release-index')) {
37 const out = []
38 for (const v of versions) {
39 for (const n of (v.names || ['tree.json', 'worklet.bundle'])) {
40 out.push({ path: `favela-android/versions/${v.version}/${n}`, repo: 'favela-android', version: v.version, mtimeMs: v.builtAt })
41 }
42 }
43 return { ok: true, async json () { return { result: { files: out } } } }
3544 }
3645 const hit = Object.keys(files).find((k) => url.endsWith(k))
3746 if (!hit) return { ok: false, status: 404 }
……@@ -240,3 +249,36 @@ test('a malformed current.json is ignored rather than thrown on', (t) => {
240249 t.is(chooseBundle({ current: {}, pending: null, failed: [] }).use, 'asset')
241250 t.is(chooseBundle({ current: { treeHash: 'aaa' }, pending: null, failed: [] }).use, 'asset', 'no file, no boot')
242251})
252
253test('an INCOMPLETE release is skipped for the newest complete one', async (t) => {
254 // Measured on the forge 2026-08-18: master-5d6d7e4 served build.log, manifest.json and the APK while
255 // tree.json and worklet.bundle never replicated. Taking "newest" blindly pins a phone to a version it
256 // can never install and retries it forever, so the rule is newest COMPLETE.
257 const { entries, treeHash } = treeFor(BUNDLE)
258 const good = 'master-53b10d2'
259 const partial = 'master-5d6d7e4'
260
261 const doFetch = fakeForge({
262 versions: [
263 { version: good, builtAt: 1000, names: ['tree.json', 'worklet.bundle'] },
264 { version: partial, builtAt: 5000, names: ['build.log', 'manifest.json', 'favela-android.apk'] }
265 ],
266 files: {
267 [good + '/tree.json']: Buffer.from(JSON.stringify({ treeHash, entries })),
268 [good + '/worklet.bundle']: BUNDLE
269 }
270 })
271
272 const latest = await bundleFetcher({ base: 'https://forge/git', repo: 'favela-android', doFetch }).getLatestRelease('favela-android')
273 t.ok(latest, 'a complete release is found')
274 t.is(latest.version, good, 'the NEWER but incomplete release is skipped')
275})
276
277test('no complete release at all is null, not a crash', async (t) => {
278 const doFetch = fakeForge({
279 versions: [{ version: 'master-5d6d7e4', builtAt: 5000, names: ['build.log', 'favela-android.apk'] }],
280 files: {}
281 })
282 const latest = await bundleFetcher({ base: 'https://forge/git', repo: 'favela-android', doFetch }).getLatestRelease('favela-android')
283 t.is(latest, null)
284})