OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web commits / 83e5944

the two halves the phone needs, not yet wired to the server
⚠ INERT AS COMMITTED. Both additions are exported and tested and NOTHING CALLS THEM YET: the two lines that would, in bin/favela-web.js, are deliberately not in this commit. Deployed behaviour is byte-identical to 0.3.79. Committed anyway rather than left in a working tree overnight — the code is finished and green, it is the wiring that is pending review. 1. chooseNameservers / parseNameserverList (lib/dns-txt.js) The resolver never needed changing; it only ever needed somewhere to send the query. On Android there IS no /etc/resolv.conf — netd owns resolution and `net.dns1` has been empty since Android 8 (confirmed on the handset tonight) — so favela-web logs "DNS TXT handle verification is OFF" there and every custom-domain handle renders handle.invalid, which by our own 2026-08-24 measurement is most of the interesting identities on the network. FAVELA_NAMESERVERS lets an embedder pass the OS's own resolvers for the active network. Still the network's resolver, not a third party's — the objection recorded at the resolver's construction ("handle verification is the last place to hand a stranger the list of every identity we look up") applies to plain DNS as much as to DoH, so this is a handoff and never a default. Env first, file second, never merged: an embedder that has named its resolvers is not second-guessed. But an UNUSABLE env value falls through to resolv.conf rather than shadowing it — exporting the variable wrong must not turn verification off on a server. ⛔ A character-class test is not an address test. `[0-9a-fA-F:.]+` admits `8.8.8.8:53`, because ':' has to be allowed for IPv6 — and a port smuggled into the host argument is a connect() to a host that does not exist. The two families are parsed apart, and `::ffff:1.2.3.4` still passes. 2. appUpdateSection (lib/render.js) Rendered ONLY when the shell has claimed the capability, because the app shell ships in an APK and the worklet bundle arrives by OTA — either can be older. A new bundle inside an old shell must show no controls, not dead ones. Signed OUT as well, deliberately: a restart is what repairs a session this page has forgotten, and gating that behind sign-in hides it exactly when it is needed. ⛔ staged === running is NOT an update, and says so. That is the bug this page exists not to repeat: the OTA log announced "staged … — active on next start" naming the bundle already in memory, and putting that on a screen would be the same lie with better typography. One-way postMessage, no reply channel: the shell acts, the worklet writes the state to disk, and this page re-renders it SERVER-side. A reply channel would be a second source of truth for the same three fields. Suite green: render 406 assertions, dns-txt 39, full suite exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
83e59441401c649f6775ecbd466ef97c80fa02c3
by plankatron <fleeky@gmail.com> · 2026-08-25T02:26:22.000Z

Changed files (4) +176 -5

lib/dns-txt.js +49 -0
……@@ -49,6 +49,55 @@ export function parseResolvConf (text) {
4949 return out
5050}
5151
52// ⛔ ANDROID HAS NO /etc/resolv.conf. netd owns resolution there and `getprop net.dns1` has been empty
53// since Android 8, so the handset booted with `DNS TXT handle verification is OFF` — the bare-dns crash
54// was fixed and the CAPABILITY never arrived: every custom-domain handle renders handle.invalid on the
55// phone, which by our own measurement is most of the interesting identities on the network.
56//
57// Nothing about the resolver needed changing; it only ever needed somewhere to send the query. So the
58// embedder may pass one in. On Android that is ConnectivityManager's own DNS list for the active
59// network — the SAME resolver the OS uses, not a third party, which is why this is an env handoff and
60// NOT a hardcoded 1.1.1.1: handle verification is the last place to hand a stranger the list of every
61// identity we look up (see the note at the resolver's construction in bin/favela-web.js).
62//
63// Addresses only, no ports, no hostnames: this value is handed straight to connect(), and a hostname
64// would need a resolver to resolve — which is the thing we are trying to obtain.
65//
66// ⛔ A CHARACTER-CLASS TEST IS NOT ENOUGH. `[0-9a-fA-F:.]+` admits `8.8.8.8:53`, because ':' has to be
67// allowed for IPv6 — and a port smuggled into the host argument is a connect() to a host that does not
68// exist. So the two families are parsed apart.
69function isIpv4 (s) {
70 const parts = s.split('.')
71 return parts.length === 4 && parts.every((o) => /^\d{1,3}$/.test(o) && Number(o) <= 255)
72}
73function isIpv6 (s) {
74 if (!s.includes(':')) return false
75 if ((s.match(/::/g) || []).length > 1) return false
76 const parts = s.split(':')
77 if (parts.length > 8) return false
78 return parts.every((p, i) => {
79 if (p === '') return true // the '::' compression, and a leading/trailing colon of it
80 if (i === parts.length - 1 && p.includes('.')) return isIpv4(p) // ::ffff:1.2.3.4
81 return /^[0-9a-fA-F]{1,4}$/.test(p)
82 })
83}
84const isAddress = (s) => typeof s === 'string' && s.length > 0 && s.length <= 45 && (isIpv4(s) || isIpv6(s))
85
86export function parseNameserverList (value) {
87 if (typeof value !== 'string') return []
88 return value.split(/[,\s]+/).map((s) => s.trim()).filter(isAddress)
89}
90
91// Env first, resolv.conf second. Deliberately not "merge both": if an embedder has said which resolvers
92// to use, silently appending the file's would send some queries somewhere it did not choose.
93export function chooseNameservers ({ envValue = null, resolvConf = null } = {}) {
94 const fromEnv = parseNameserverList(envValue)
95 if (fromEnv.length) return { servers: fromEnv, source: 'env' }
96 const fromFile = parseResolvConf(resolvConf)
97 if (fromFile.length) return { servers: fromFile, source: 'resolv.conf' }
98 return { servers: [], source: 'none' }
99}
100
52101export function encodeQuery (name, id) {
53102 if (typeof name !== 'string' || !name) throw new Error('name required')
54103 const labels = name.replace(/\.$/, '').split('.')
lib/render.js +61 -3
……@@ -1196,10 +1196,67 @@ export function versionNote () {
11961196 return `<p class="vernote">favela-web <code>${esc(VERSION)}</code></p>`
11971197}
11981198
1199export function settingsPage ({ viewer = null, profile = null, did = null, devices = [], theme = null } = {}) {
1199// App updates, on a handset only. Rendered ONLY when the native shell has said it can service these
1200// buttons (`shell.ota`), because the shell and the worklet bundle version INDEPENDENTLY — the bundle
1201// arrives by OTA, the shell only when an APK is installed — so a new bundle can find itself inside an
1202// old shell that has no message handler. Buttons that silently do nothing are worse than no buttons.
1203//
1204// ⛔ WHY THIS EXISTS AT ALL. Staging and applying are deliberately separate (applying to a running node
1205// means swapping code under itself), so an update discovered during boot N can only take effect at boot
1206// N+1 — and with a 6-hour poll and no way to restart the node, "up to date" is a thing the phone can
1207// only become by accident. Measured on a handset 2026-08-24: the device ran a bundle whose replacement
1208// had been staged for over half an hour with no user-reachable way to apply it.
1209//
1210// This does NOT make updates event-driven; the 6h poll is still the last polling loop in the stack and
1211// announce-over-swarm is still the real fix. It makes the existing design operable by the person holding
1212// the phone.
1213//
1214// Signed OUT as well, on purpose: a stale session is one of the states a restart repairs, and gating the
1215// repair on being signed in would put it behind the thing that is broken.
1216export function appUpdateSection (shell = null) {
1217 if (!shell || !shell.ota) return ''
1218 const short = (h) => (typeof h === 'string' && h.length > 12 ? h.slice(0, 12) + '…' : (h || ''))
1219 const running = shell.running ? `<code>${esc(short(shell.running))}</code>` : '<span class="empty">the bundle shipped in the app</span>'
1220 // "staged" is only news when it is NOT what is already running — otherwise it reads as a pending
1221 // update to someone who is already on it, which is the exact lie the OTA log used to tell.
1222 const pending = shell.staged && shell.staged !== shell.running
1223 const stagedRow = pending
1224 ? `<p class="otapend">An update is staged: <code>${esc(short(shell.staged))}</code> — restart the node to run it.</p>`
1225 : '<p class="empty">No update staged.</p>'
1226 const checked = shell.checkedAt ? `<p class="empty">last checked ${esc(relTime(shell.checkedAt))}</p>` : ''
1227 return `<section class="authbox otasec">
1228 <h3>App updates</h3>
1229 <p class="empty">running <span class="otarun">${running}</span></p>
1230 ${stagedRow}
1231 ${checked}
1232 <p><button class="devbtn" data-ota="check">check for updates</button>
1233 <button class="devbtn${pending ? ' otaprim' : ''}" data-ota="restart">restart the node</button></p>
1234 <p class="empty">Checking downloads in the background; nothing changes until the node restarts. A restart
1235 also re-signs this page in, which is the fix if it has started refusing you.</p>
1236<style>.otapend{margin:.3rem 0}.otaprim{border-color:#2563eb}.otarun code{font-size:.85rem}</style>
1237<script>
1238// One-way to the shell, deliberately: it acts and then reloads this page, so the state above is always
1239// SERVER-rendered from what the worklet actually knows. A reply channel would be a second source of
1240// truth for the same three fields.
1241document.querySelectorAll('[data-ota]').forEach(function (b) {
1242 b.addEventListener('click', function () {
1243 var bridge = window.ReactNativeWebView
1244 if (!bridge) return
1245 b.disabled = true
1246 b.textContent = b.dataset.ota === 'restart' ? 'restarting…' : 'checking…'
1247 bridge.postMessage(JSON.stringify({ t: 'app-update', op: b.dataset.ota }))
1248 })
1249})
1250</script>
1251</section>`
1252}
1253
1254export function settingsPage ({ viewer = null, profile = null, did = null, devices = [], theme = null, shell = null } = {}) {
12001255 if (!viewer) {
1201 // Appearance still renders here — it is not an account setting.
1202 return page({ title: 'settings · favela', viewer, active: null, theme, body: '<div class="viewhdr">Settings <a href="/">← back</a></div>' + themeControl(theme) + versionNote() + '<p class="empty">Sign in to manage your profile. <a href="/login">sign in →</a></p>' })
1256 // Appearance still renders here — it is not an account setting. So does App updates: a restart is
1257 // what repairs a session this page has forgotten, and hiding it behind sign-in hides it exactly when
1258 // it is needed.
1259 return page({ title: 'settings · favela', viewer, active: null, theme, body: '<div class="viewhdr">Settings <a href="/">← back</a></div>' + themeControl(theme) + versionNote() + appUpdateSection(shell) + '<p class="empty">Sign in to manage your profile. <a href="/login">sign in →</a></p>' })
12031260 }
12041261 const cid = profile && profile.avatar && blobCid(profile.avatar.ref)
12051262 const current = (cid && did && isSafeCid(cid))
……@@ -1224,6 +1281,7 @@ export function settingsPage ({ viewer = null, profile = null, did = null, devic
12241281 body: `<div class="viewhdr">Settings <a href="/">← back</a></div>
12251282${themeControl(theme)}
12261283${versionNote()}
1284${appUpdateSection(shell)}
12271285<section class="authbox">
12281286 <h3>Profile picture</h3>
12291287 <p class="empty">Signed in as <strong>${esc(viewer.did)}</strong>${profile && profile.displayName ? ' · ' + esc(profile.displayName) : ''}</p>
test/dns-txt.test.mjs +26 -1
……@@ -6,7 +6,8 @@
66// is a redirect, a 404 or nothing. So this codec is on the path for most real identities, and the
77// tests that matter are the malformed-input ones: it parses bytes from the network.
88import {
9 parseResolvConf, encodeQuery, decodeResponse, frame, createTcpResolveTxt, RCODE
9 parseResolvConf, parseNameserverList, chooseNameservers,
10 encodeQuery, decodeResponse, frame, createTcpResolveTxt, RCODE
1011} from '../lib/dns-txt.js'
1112
1213let pass = 0
……@@ -19,6 +20,30 @@ ok(parseResolvConf('# nameserver 8.8.8.8\n').length === 0, 'comments are not nam
1920ok(JSON.stringify(parseResolvConf('nameserver 1.1.1.1\nnameserver 8.8.8.8')) === '["1.1.1.1","8.8.8.8"]', 'order is preserved')
2021ok(parseResolvConf(null).length === 0, 'garbage in, empty out')
2122
23// ── where the servers come from ──────────────────────────────────────────────────────────────────
24// Android has no /etc/resolv.conf — netd owns resolution — so a handset found no nameserver and turned
25// TXT verification OFF, which by the measurement above means most identities render handle.invalid
26// there. FAVELA_NAMESERVERS is how an embedder passes the OS's own resolvers in.
27ok(JSON.stringify(parseNameserverList('192.168.1.1,8.8.8.8')) === '["192.168.1.1","8.8.8.8"]', 'a comma list is parsed')
28ok(JSON.stringify(parseNameserverList(' 1.1.1.1 8.8.4.4 ')) === '["1.1.1.1","8.8.4.4"]', 'whitespace separates too, and is trimmed')
29ok(parseNameserverList('2606:4700:4700::1111').length === 1, 'IPv6 is an address')
30// ⛔ Addresses only. This value is handed to connect(); a hostname would need a resolver to resolve,
31// which is the thing being obtained, and anything else is junk arriving at a socket call.
32ok(parseNameserverList('dns.google').length === 0, 'a hostname is not an address')
33ok(parseNameserverList('8.8.8.8:53').length === 0, 'a port is not part of an address here')
34ok(parseNameserverList('').length === 0 && parseNameserverList(null).length === 0, 'empty and null yield nothing')
35ok(parseNameserverList('...').length === 0, 'punctuation alone is not an address')
36
37const envWins = chooseNameservers({ envValue: '10.0.0.1', resolvConf: 'nameserver 127.0.0.53' })
38ok(envWins.source === 'env' && JSON.stringify(envWins.servers) === '["10.0.0.1"]', 'an embedder that names resolvers is not second-guessed')
39const file = chooseNameservers({ envValue: '', resolvConf: 'nameserver 127.0.0.53' })
40ok(file.source === 'resolv.conf' && file.servers.length === 1, 'no env => the file, exactly as before')
41const neither = chooseNameservers({ envValue: null, resolvConf: null })
42ok(neither.source === 'none' && neither.servers.length === 0, 'neither => none, and the caller disables the DNS path')
43// A junk env value must not SHADOW a working resolv.conf — that would turn verification off on a server
44// because someone exported the variable wrong.
45ok(chooseNameservers({ envValue: 'dns.google', resolvConf: 'nameserver 1.1.1.1' }).source === 'resolv.conf', 'an unusable env value falls through to the file')
46
2247// ── query encoding ───────────────────────────────────────────────────────────────────────────────
2348const q = encodeQuery('_atproto.pfrazee.com', 0x1234)
2449ok((q[0] << 8 | q[1]) === 0x1234, 'query carries the id we asked for')
test/render.test.mjs +40 -1
……@@ -1,5 +1,5 @@
11// favela SSR render layer — pure, runtime-agnostic (no P2P/index/Bare). Run: node test/render.test.mjs
2import { withReach, renderReach, esc, renderText, renderPost, renderPosts, renderEmbed, blobCid, timelinePage, profilePage, tagPage, renderAtom, settingsPage, postPage, relTime, shortDid, freshnessNote, postHref, renderRichText, withViewer, searchPage, bookmarksPage, notificationsPage, listsPage, listPage, followListPage, emptyState } from '../lib/render.js'
2import { withReach, renderReach, esc, renderText, renderPost, renderPosts, renderEmbed, blobCid, timelinePage, profilePage, tagPage, renderAtom, settingsPage, postPage, relTime, shortDid, freshnessNote, postHref, renderRichText, withViewer, searchPage, bookmarksPage, notificationsPage, listsPage, listPage, followListPage, emptyState, appUpdateSection } from '../lib/render.js'
33import { SCOPE_COLLECTION } from '../lib/scopes.js'
44
55let pass = 0
……@@ -917,4 +917,43 @@ function t_is_empty (s, m) { ok(s === '', m) }
917917 not(timelinePage({ result: { posts: [] } }), 'class="stale"', 'and never when no freshness was supplied')
918918}
919919
920// ── app updates (handset only) ───────────────────────────────────────────────────────────────────
921// The controls exist because staging and applying are separate: an update discovered at boot N can only
922// run at boot N+1, and with a 6h poll and no restart button the phone can only become current by
923// accident. Measured 2026-08-24: a handset ran a bundle whose replacement had been staged for half an
924// hour with no user-reachable way to apply it.
925{
926 const H = 'eaa75a4bd017a3e0ae030aaf0bb49eab655ebd6fa9fd82232e810e498e2e8647'
927 const N = 'ffff5a4bd017a3e0ae030aaf0bb49eab655ebd6fa9fd82232e810e498e2e8647'
928
929 // ⛔ The shell (an APK) and the worklet bundle (OTA) version independently, so a new bundle can find
930 // itself inside an old shell with no message handler. Buttons that silently do nothing are worse than
931 // no buttons — hence a capability claim, not a guess.
932 ok(appUpdateSection(null) === '', 'no shell => no section at all (this is also every server)')
933 ok(appUpdateSection({ ota: false, running: H }) === '', 'a shell that cannot service the buttons gets none')
934
935 const staged = appUpdateSection({ ota: true, running: H, staged: N, checkedAt: '2026-08-24T21:41:31.000Z' })
936 has(staged, 'An update is staged', 'a genuinely newer bundle is announced')
937 has(staged, 'data-ota="restart"', 'and the restart that applies it is right there')
938 has(staged, 'data-ota="check"', 'checking is not gated on anything')
939
940 // ⛔ THE BUG THIS PAGE MUST NOT REPEAT. The OTA log announced "staged … — active on next start" naming
941 // the bundle already running, because currentTreeHash was never passed. Rendering that as a pending
942 // update would put the same lie on a screen.
943 const same = appUpdateSection({ ota: true, running: H, staged: H })
944 has(same, 'No update staged', 'staged === running is not an update')
945 not(same, 'An update is staged', 'and is never announced as one')
946
947 const apk = appUpdateSection({ ota: true, running: null, staged: null })
948 has(apk, 'the bundle shipped in the app', 'no OTA bundle yet reads as the APK’s own, not as unknown')
949 not(appUpdateSection({ ota: true, running: H }), 'last checked', 'no check yet => no invented timestamp')
950
951 // It reaches the real page, signed in AND signed out — a restart is what repairs a session this page
952 // has forgotten, so gating it on being signed in would hide it exactly when it is needed.
953 const shell = { ota: true, running: H, staged: N }
954 has(settingsPage({ viewer: null, shell }), 'App updates', 'the section renders signed out')
955 has(settingsPage({ viewer: { did: 'did:plc:alice' }, shell }), 'App updates', 'and signed in')
956 not(settingsPage({ viewer: null }), 'App updates', 'and never without a shell — this is a phone-only control')
957}
958
920959console.log(`\nRENDER OK: ${pass} assertions`)