OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android commits / e7e7ebd

0.0.3: the phone subscribes to its own DIDs instead of guessing
favela-web reads FAVELA_ROSTER at module top level and prepareEnv() never set it, so the in-process favela-web booted with `joined 0 DID topic(s)`. Nothing threw. The phone simply subscribed to no one, and the index every screen reads could only ever contain what was already in it. The cost was invisible from both sides. Records authored on another co-owner of the phone's OWN DID replicated fine — pointers converged (checked 183->184->185), bytes fetched, and the worklet's reindex() logged a healthy "indexed ... why: statebase-update" each time. It was folding them into a SECOND appview that no screen reads (the one node.js:526 already warned about). Measured 2026-08-18: three posts from tabertron, still 404 on the handset two and a half hours and two boots later. Set the roster before the favela-web import, from every hosted DID plus the tracked ones. Hosted DIDs are in it because we INDEX them, not because we sign for them — the signing set is still one control socket (favela-web lib/writer.js:283), and nothing may read authority out of roster membership. addPeer's coHosted branch baselines a hosted DID from the shared store and tails from the current end, so this refetches nothing already on disk. All hosted DIDs, not nodes[0]: a handset can house two accounts, reads are already multi-DID, and half-subscribing the second would look like a timeline quietly missing a person. test/roster.test.mjs pins favela-web's own parser verbatim as the control, so a change to how it reads the env fails here rather than on a phone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
e7e7ebd2dd2635ee16cc74ef3f12022c55994192
by plankatron <fleeky@gmail.com> · 2026-08-18T22:29:20.000Z

Changed files (4) +176 -2

lib/node.js +21 -0
……@@ -20,6 +20,7 @@ import fs from 'bare-fs'
2020import path from 'bare-path'
2121import b4a from 'b4a'
2222import { createUtf8Decoder } from './utf8.js'
23import { rosterEnv } from './roster.js'
2324
2425const IPC = typeof BareKit !== 'undefined' ? BareKit.IPC : null
2526
……@@ -238,6 +239,21 @@ async function boot () {
238239 const ctlSock = state.hpp && state.hpp.nodes && state.hpp.nodes[0] && state.hpp.nodes[0].uds
239240 if (ctlSock) process.env.FAVELA_HPP_SOCK = ctlSock
240241 else log('hpp exposed no control socket — favela-web will refuse writes (reads unaffected)')
242 // THE READ PATH, and it was never told either. favela-web SUBSCRIBES to its roster: per DID it
243 // resolves a verifyKey, joins the topic, and tails the freshest verified co-owner's firehose,
244 // applying each #commit incrementally (bin/favela-web.js:565-687). It reads FAVELA_ROSTER at module
245 // top level, so this must be set BEFORE the import below — and it never was. The phone therefore
246 // booted with `joined 0 DID topic(s)` and an index that could only contain what was already in it:
247 // records authored on another co-owner of our OWN DID reached the overlay store (pointers converged,
248 // bytes fetched) and never reached the index every screen reads. Measured 2026-08-18 — three posts,
249 // 404 on the handset two and a half hours and two boots later, with nothing in any log to say so.
250 //
251 // Hosted DIDs are in the roster because we INDEX them, not because we sign for them — the signing set
252 // is the one control socket above. addPeer's coHosted branch baselines a hosted DID from the shared
253 // store and tails from the current end, so this costs no refetch of what is already on disk.
254 const roster = rosterEnv({ hosted: hostedDids(), tracked: readTracked() })
255 if (roster) process.env.FAVELA_ROSTER = roster
256 log('favela-web roster', { dids: roster ? roster.split(',').length : 0 })
241257 fs.mkdirSync(process.env.FAVELA_STORE_ROOT, { recursive: true })
242258 await import('favela-web/bin/favela-web.js')
243259 state.favelaUrl = 'http://127.0.0.1:' + FAVELA_PORT
……@@ -326,6 +342,11 @@ async function p2pSource () {
326342
327343const TRACKED = () => path.join(dirs.root, 'tracked-dids.json')
328344function readTracked () { try { return JSON.parse(fs.readFileSync(TRACKED(), 'utf8')) } catch { return [] } }
345// EVERY hosted DID, not nodes[0]. A handset can house two accounts — reads are already multi-DID (one
346// index, DID-keyed, `viewerDid` per query), so half-subscribing the second one would show up as a
347// timeline quietly missing a person rather than as an error. (Writes are still single-DID: one control
348// socket, see favela-web/lib/writer.js:283. Roster membership carries no authority.)
349function hostedDids () { return ((state.hpp && state.hpp.nodes) || []).map((n) => n && n.did).filter(Boolean) }
329350function writeTracked (list) { try { fs.writeFileSync(TRACKED(), JSON.stringify(list, null, 2) + '\n') } catch {} }
330351
331352const handlers = {
lib/roster.js +72 -0
……@@ -0,0 +1,72 @@
1// Which DIDs this node SUBSCRIBES to — the value favela-web reads as FAVELA_ROSTER.
2//
3// ⛔ ROSTER ≠ SIGNING SET. The roster is what this node INDEXES: it joins each DID's topic, resolves its
4// verifyKey, and follows the freshest verified co-owner's firehose (favela-web/bin/favela-web.js:565-687).
5// Every entry is read-only and independently verified. What this node can SIGN for is a different, much
6// smaller thing — one hpp control socket (favela-web/lib/writer.js:283, "favela's write path is
7// single-DID (the UDS socket picks the device)"). Our own hosted DIDs appear here because we index them,
8// NOT because we hold their keys, and nothing downstream may read authority out of roster membership.
9//
10// ⛔ WHY THIS EXISTS AT ALL. favela-web captures the roster at module top level, so it is frozen at
11// import. The phone never set it, so the in-process favela-web joined zero topics and its index — the one
12// every screen reads — could only ever contain what was already in it. See test/roster.test.mjs, whose
13// first test replicates favela-web's parser verbatim against the value we used to hand it.
14//
15// Pure and IO-free on purpose: the phone's boot is expensive to stand up, and the part worth testing is
16// this decision, not the boot.
17
18// A DID we are willing to turn into a topic join and a verifyKey resolution.
19//
20// The comma exclusion is load-bearing rather than cosmetic: the env format is comma-separated, so a DID
21// carrying a comma would arrive at favela-web as TWO entries, each garbage. Whitespace is excluded for
22// the same reason the parser trims — an entry that only survives trimming is an entry we should not have
23// emitted. `did:` alone, or any method with no identifier, is not a DID and must not reach
24// resolveVerifyKey, where it would cost a network round-trip to learn what a regex already knows.
25const DID = /^did:[a-z][a-z0-9]*:[A-Za-z0-9._%:-]+$/
26
27function usable (v) {
28 return typeof v === 'string' && DID.test(v)
29}
30
31/**
32 * The subscription set, in the order favela-web will open it.
33 *
34 * Hosted first, deliberately: `addPeer()` runs in roster order, and `apProjected()` asks about
35 * `ROSTER[0]` specifically (bin/favela-web.js:351-352) — so a tracked stranger sorting ahead of our own
36 * DID would quietly change which actor the ActivityPub side reports on.
37 *
38 * ALL hosted DIDs, not the first: reads are already multi-DID (one appview, DID-keyed, `viewerDid` per
39 * query), so a phone housing two accounts is a read-path reality today. Taking `nodes[0]` here would
40 * half-subscribe the second account — visible as a timeline that is simply missing one person's posts,
41 * with nothing in the log to say so.
42 *
43 * @param {object} opts
44 * @param {string[]} [opts.hosted] DIDs whose repos this node co-owns (hpp nodes)
45 * @param {string[]} [opts.tracked] DIDs explicitly tracked by the user (tracked-dids.json)
46 * @param {string[]} [opts.following] DIDs the viewer follows, from the index
47 * @returns {string[]} deduped, ordered, every entry safe to hand to favela-web
48 */
49export function rosterFor ({ hosted = [], tracked = [], following = [] } = {}) {
50 const out = []
51 const seen = new Set()
52 for (const list of [hosted, tracked, following]) {
53 if (!Array.isArray(list)) continue
54 for (const did of list) {
55 if (!usable(did) || seen.has(did)) continue
56 seen.add(did)
57 out.push(did)
58 }
59 }
60 return out
61}
62
63/**
64 * The same set as the env string favela-web parses.
65 *
66 * Empty means empty: `String(undefined)` is the literal 'undefined', which favela-web would happily take
67 * for a DID and then fail to resolve, so a node with nothing to subscribe to must produce '' — the same
68 * silence as before, rather than one fabricated peer.
69 */
70export function rosterEnv (opts) {
71 return rosterFor(opts).join(',')
72}
package.json +2 -2
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-android",
3 "version": "0.0.2",
3 "version": "0.0.3",
44 "description": "favela-android — a React Native client over a bare-kit worklet running a FULL favela node (hyper-proto-peer + appview + hyperswarm) on the phone. favela-web is reference for the logic, NOT the rendering: this is a different rendering surface. See spec/architecture.md.",
55 "private": true,
66 "type": "module",
……@@ -9,7 +9,7 @@
99 "pack:worklet": "bare-pack --preset android --defer bare-abstract-socket --out android/app/src/main/assets/worklet.bundle lib/node.js",
1010 "stage:addons": "node scripts/stage-addons.mjs",
1111 "prebuild:android": "npm run pack:worklet && npm run stamp:worklet && npm run gen:tree && npm run stage:addons",
12 "test": "bare test/adopt.test.mjs && bare test/ipc.test.mjs && bare test/session.test.mjs && bare test/favela-api.test.mjs && bare test/lifecycle.test.mjs && bare test/describe.test.mjs && bare test/ota.test.mjs && bare test/ota-e2e.test.mjs",
12 "test": "bare test/adopt.test.mjs && bare test/ipc.test.mjs && bare test/session.test.mjs && bare test/favela-api.test.mjs && bare test/lifecycle.test.mjs && bare test/roster.test.mjs && bare test/describe.test.mjs && bare test/ota.test.mjs && bare test/ota-e2e.test.mjs",
1313 "stamp:worklet": "node -e \"const fs=require('fs');const p='android/app/src/main/assets/worklet.bundle';fs.writeFileSync(p+'.size',String(fs.statSync(p).size))\"",
1414 "gen:tree": "bare scripts/gen-tree-manifest.mjs"
1515 },
test/roster.test.mjs +81 -0
……@@ -0,0 +1,81 @@
1// Which DIDs the phone's in-process favela-web subscribes to.
2//
3// ⛔ THE CONTROL BELOW IS THE BUG. favela-web reads its roster at MODULE TOP LEVEL:
4//
5// const ROSTER = (process.env.FAVELA_ROSTER || '').split(',').map((s) => s.trim()).filter(Boolean)
6//
7// (bin/favela-web.js:152). `prepareEnv()` never set FAVELA_ROSTER, so on the phone that expression
8// evaluated to `[]`, `rosterDids` was empty, `addPeer()` was never called, and the boot logged
9//
10// [favela] joined 0 DID topic(s); indexing over P2P...
11//
12// Nothing was wired wrong and nothing threw. The phone simply subscribed to no one, so every record
13// authored anywhere else — including on another co-owner of ITS OWN DID — reached its overlay store and
14// never reached the index every screen reads. Measured 2026-08-18: three posts, pointers converged
15// (checked 183→184→185, bytes fetched), still 404 on the phone two and a half hours and two boots later.
16//
17// So the first test replicates favela-web's parser VERBATIM against the value the phone used to hand it,
18// pinning the defect; the rest assert the roster that replaces it.
19//
20// ⛔ AND THE ROSTER IS NOT THE SIGNING SET. A roster is what this node INDEXES (many DIDs, read-only,
21// verified over the firehose). What it SIGNS for is one control socket (lib/writer.js:283). Conflating
22// them is how a write lands in someone else's repo, so `hosted` DIDs are in the roster because we index
23// them, not because we sign for them.
24import test from 'brittle'
25import { rosterFor, rosterEnv } from '../lib/roster.js'
26
27const WJ62 = 'did:plc:wj62ebbusln6gxo5azo6ogzo'
28const E2JEV = 'did:plc:e2jevhuurrhlm36ufk7fklpg'
29const WQNW = 'did:plc:wqnwulql7ndcexample00000'
30
31// favela-web/bin/favela-web.js:152, copied byte-for-byte. If favela-web ever changes how it parses the
32// env, this test fails and tells us — which is the point of pinning the CONSUMER's parser rather than
33// asserting our own idea of the format.
34const parseAsFavelaWebDoes = (v) => String(v || '').split(',').map((s) => s.trim()).filter(Boolean)
35
36test('control: what the phone used to hand favela-web parsed to nobody', (t) => {
37 t.alike(parseAsFavelaWebDoes(undefined), [], 'unset FAVELA_ROSTER -> no DIDs -> joined 0 DID topic(s)')
38 t.alike(parseAsFavelaWebDoes(''), [], 'empty string is the same silence')
39})
40
41test('the roster round-trips through favela-web own parser', (t) => {
42 const env = rosterEnv({ hosted: [WJ62], tracked: [E2JEV] })
43 t.alike(parseAsFavelaWebDoes(env), [WJ62, E2JEV], 'what we set is exactly what favela-web reads back')
44})
45
46test('hosted DIDs come first', (t) => {
47 // addPeer() runs in order and the first roster entry is also what apProjected() asks about
48 // (bin/favela-web.js:351-352), so a tracked stranger must not displace our own DID.
49 t.alike(rosterFor({ hosted: [WJ62], tracked: [E2JEV, WQNW] }), [WJ62, E2JEV, WQNW])
50})
51
52test('every hosted DID is carried, not just the first', (t) => {
53 // Two accounts on one handset. Reads are already multi-DID; taking nodes[0] here would silently
54 // half-subscribe the second user.
55 t.alike(rosterFor({ hosted: [WJ62, E2JEV] }), [WJ62, E2JEV])
56})
57
58test('deduped across sources', (t) => {
59 // A hosted DID you also follow, or a tracked DID you later co-own. addPeer() dedups by `byDid`, but a
60 // duplicate here would still be a lie in the logged topic count.
61 t.alike(rosterFor({ hosted: [WJ62], tracked: [WJ62], following: [WJ62, E2JEV] }), [WJ62, E2JEV])
62})
63
64test('junk is dropped rather than passed through', (t) => {
65 // tracked-dids.json is a file on disk that a crash can truncate; `following` comes off the index.
66 // A malformed entry must not become a topic join or a verifyKey resolution attempt.
67 t.alike(rosterFor({ hosted: [WJ62], tracked: ['', ' ', null, undefined, 42, 'not-a-did', 'did:'] }), [WJ62])
68})
69
70test('a comma inside a DID can never split one entry into two', (t) => {
71 // The env format is comma-separated, so a DID containing a comma would silently become two bogus
72 // entries in favela-web. Nothing should be able to smuggle one through.
73 t.alike(rosterFor({ tracked: ['did:plc:aaa,did:plc:bbb'] }), [], 'rejected, not split')
74})
75
76test('an empty roster yields an empty string, not the word undefined', (t) => {
77 // `String(undefined)` is 'undefined', which would parse to one garbage DID. A node with nothing to
78 // subscribe to must produce the same silence as before, not a fake peer.
79 t.is(rosterEnv({}), '')
80 t.alike(parseAsFavelaWebDoes(rosterEnv({})), [])
81})