OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android commits / 99f8195

adopt the roster the room already lists when ours is empty
A device can be a listed member of a room while its OWN config names no members — the state this phone reached: handed the DID's signing key, never rostered, then admitted from stortron by device key. The room defends itself against the consequence forever, once every 30s: projectMembers REFUSED: empty roster would wipe 3 existing member(s) The guard is right and the config is what is stale, so take what the room already says. ⛔ Only when the roster is empty under ALL THREE on-disk shapes — array of hex, array of {key,role}, or comma-separated string. wj62 carries the string form in production, and an Array.isArray check reads that as empty, which here would mean overwriting a real roster with our nothing. That is the same bug hyper-proto-peer's coowner.js already fixed once; lib/roster.js exists so the rule lives in one tested place instead of being re-derived per call site. The config is backed up before the write — it is the file that names the identity. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
99f8195f94befd2dd42c6016f80dd99e1b2eff06
by plankatron <fleeky@gmail.com> · 2026-08-12T00:31:25.000Z

Changed files (4) +104 -1

lib/node.js +24 -0
……@@ -106,6 +106,30 @@ async function boot () {
106106 }
107107 } catch (e) { log('could not derive the device public key', { error: (e && e.message) || String(e) }) }
108108 state.hpp = await bootMultiDevice({ storage: dirs.hpp, dids: cfg.dids || [], deviceMnemonic: cfg.deviceMnemonic || null })
109 // LOCAL ROSTER REPAIR. A device can be a listed member of the room while its OWN config names no
110 // members — the state this phone reached: handed the DID's signing key, never rostered, then
111 // admitted from stortron by device key. The room defends itself against the consequence, forever,
112 // once every 30s: "projectMembers REFUSED: empty roster would wipe 3 existing member(s)". The guard
113 // is right; the config is what is stale. So adopt what the room already says.
114 //
115 // ⛔ ONLY WHEN GENUINELY EMPTY, under all three on-disk shapes (see lib/roster.js) — reading a
116 // populated comma-string roster as empty is how you overwrite a real one. And back the file up
117 // first: this rewrites the file that names the identity.
118 try {
119 const { rosterIsEmpty } = await import('./roster.js')
120 const entry0 = (cfg.dids || [])[0]
121 const node0 = state.hpp && state.hpp.nodes && state.hpp.nodes[0]
122 if (entry0 && rosterIsEmpty(entry0) && node0 && node0.device && typeof node0.device.membersDiag === 'function') {
123 const md = await node0.device.membersDiag()
124 const inView = Object.keys((md && md.membersInView) || {})
125 if (inView.length) {
126 fs.copyFileSync(cfgPath, cfgPath + '.bak.' + Date.now())
127 entry0.members = inView
128 fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n')
129 log('local roster was empty — adopted the ' + inView.length + ' member(s) the room already lists', { members: inView.length })
130 }
131 }
132 } catch (e) { log('could not repair the local roster (harmless; the room still gates admission)', { error: (e && e.message) || String(e) }) }
109133 // ⛔ REPORT WHAT BOOTED, NOT WHAT WAS CONFIGURED. This line used to print the CONFIG's DID count,
110134 // so it said "signing enabled {dids:1}" while zero nodes had come up — the compose button renders
111135 // on `signing`, so the app looked paired and an hour went into diagnosing an empty appview when
lib/roster.js +34 -0
……@@ -0,0 +1,34 @@
1// The DID entry's member roster, as it is actually written on disk.
2//
3// ⛔ THREE SHAPES, ONE MEANING. `members` is an array of hex strings, an array of {key, role} objects,
4// OR a comma-separated string — and the string form is what wj62 carries in production
5// (hyper-proto-peer/lib/coowner.js documents this, with its own regression test, after an
6// `Array.isArray` gate read a populated string roster as EMPTY and rewrote it as [self, joiner],
7// silently dropping every other device). hyper-proto-peer's own config loader normalises all three;
8// anything that reads the raw JSON must do the same or it will eventually delete a fleet's membership.
9//
10// Kept deliberately tiny and dependency-free so the rule lives in one testable place rather than being
11// re-derived at each call site — which is how the original bug happened.
12
13/**
14 * Every member key in an entry, normalised to lower-case hex. Never throws; unknown shapes yield [].
15 * @param {object|null} entry a `dids[]` entry from the hpp config
16 * @returns {string[]}
17 */
18export function rosterKeys (entry) {
19 const raw = entry && entry.members
20 const list = Array.isArray(raw) ? raw : (typeof raw === 'string' ? raw.split(',') : [])
21 return list
22 .map((m) => String((typeof m === 'object' && m ? m.key : m) || '').trim().toLowerCase())
23 .filter(Boolean)
24}
25
26/**
27 * Does this entry name no members at all?
28 *
29 * This gates a WRITE to the config, so the cost of a wrong `true` is overwriting a real roster. It is
30 * therefore defined as "no keys under ANY of the three shapes", not "not a non-empty array".
31 */
32export function rosterIsEmpty (entry) {
33 return rosterKeys(entry).length === 0
34}
package.json +1 -1
……@@ -9,7 +9,7 @@
99 "pack:worklet": "bare-pack --preset android --defer bare-abstract-socket --defer pgh-runtime --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 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",
12 "test": "bare test/adopt.test.mjs && bare test/roster.test.mjs && bare test/ipc.test.mjs && bare test/session.test.mjs && bare test/favela-api.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 },
1515 "dependencies": {
test/roster.test.mjs +45 -0
……@@ -0,0 +1,45 @@
1// The LOCAL roster repair.
2//
3// A device can hold a DID and be listed in the room while its own config names no members at all —
4// exactly the state this phone reached (given the signing key, never rostered, then admitted from
5// stortron by device key). The symptom is a guard firing forever:
6//
7// [state-base-v3] projectMembers REFUSED: empty roster would wipe 3 existing member(s)
8//
9// which is the ROOM defending itself against this device projecting its empty roster over three real
10// members. The guard is right; the config is what is wrong.
11//
12// ⛔ THE ROSTER HAS THREE ON-DISK SHAPES and getting this wrong is how you delete a fleet's membership:
13// an array of hex strings, an array of {key, role} objects, or a COMMA-SEPARATED STRING — wj62 in
14// production is the string form (hyper-proto-peer/lib/coowner.js says so, with its own regression test).
15// An Array.isArray check treats a populated string roster as EMPTY, and "empty" here means "overwrite".
16//
17// Run: bare test/roster.test.mjs
18import { rosterKeys, rosterIsEmpty } from '../lib/roster.js'
19
20const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m) }
21const A = 'a'.repeat(64)
22const B = 'b'.repeat(64)
23
24console.log('roster shapes')
25assert(rosterKeys({ members: [A, B] }).length === 2, 'array of hex strings')
26assert(rosterKeys({ members: [{ key: A, role: 1 }, { key: B, role: 1 }] }).length === 2, 'array of {key,role} objects')
27assert(rosterKeys({ members: A + ',' + B }).length === 2, 'COMMA-SEPARATED STRING — the production shape')
28assert(rosterKeys({ members: ' ' + A + ' , ' + B + ' ' })[0] === A, 'whitespace around string entries is trimmed')
29assert(rosterKeys({ members: [{ key: A }] })[0] === A, 'object entries yield their key, not [object Object]')
30assert(rosterKeys({}).length === 0, 'absent roster')
31assert(rosterKeys({ members: [] }).length === 0, 'empty array')
32assert(rosterKeys({ members: '' }).length === 0, 'empty string')
33assert(rosterKeys(null).length === 0, 'null entry')
34assert(rosterKeys({ members: [A.toUpperCase()] })[0] === A, 'normalised to lower case, so comparisons hold')
35
36console.log('emptiness — the decision that gates a WRITE')
37assert(rosterIsEmpty({}) === true, 'absent roster is empty')
38assert(rosterIsEmpty({ members: [] }) === true, 'empty array is empty')
39assert(rosterIsEmpty({ members: '' }) === true, 'empty string is empty')
40assert(rosterIsEmpty({ members: [A] }) === false, 'one entry is NOT empty')
41// the one that matters: a populated STRING must never read as empty, or the repair overwrites a real roster
42assert(rosterIsEmpty({ members: A + ',' + B }) === false,
43 'a populated COMMA-STRING roster is NOT empty — the check that keeps this from wiping a fleet')
44
45console.log('\nall roster tests passed')