| … | … | @@ -94,22 +94,52 @@ export function bundleFetcher ({ base = DEFAULT_BASE, repo = DEFAULT_REPO, doFet |
| 94 | 94 | return { treeHash, entries } |
| 95 | 95 | } |
| 96 | 96 | |
| 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. |
| 98 | 108 | async getLatestRelease (name) { |
| 99 | | const res = await doFetch(`${base}/rpc/list-repo-releases`, { |
| 109 | const res = await doFetch(`${base}/rpc/release-index`, { |
| 100 | 110 | method: 'POST', |
| 101 | 111 | headers: { 'content-type': 'application/json' }, |
| 102 | | body: JSON.stringify({ repo }) |
| 112 | body: '{}' |
| 103 | 113 | }) |
| 104 | 114 | if (!res || res.ok === false) return null |
| 105 | 115 | 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) |
| 107 | 137 | 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. |
| 110 | 140 | 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 } |
| 113 | 143 | }, |
| 114 | 144 | |
| 115 | 145 | // ⛔ 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 |
| 122 | 152 | if (hit) return { entries: hit.entries, size: hit.entries.length } |
| 123 | 153 | // Not seen yet — the caller went straight to installTree. Read the newest release's tree and hand |
| 124 | 154 | // 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 } |
| 136 | 161 | }, |
| 137 | 162 | |
| 138 | 163 | async getContent (contentHash) { |
| … | … | @@ -151,6 +176,8 @@ export function bundleFetcher ({ base = DEFAULT_BASE, repo = DEFAULT_REPO, doFet |
| 151 | 176 | return bytes |
| 152 | 177 | } |
| 153 | 178 | } |
| 179 | |
| 180 | return api |
| 154 | 181 | } |
| 155 | 182 | |
| 156 | 183 | /** |