OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android commits / d233237

load the hpp config through hpp's own loader
The roster has three on-disk shapes and only one survives downstream: projectMembers reads `m.key`, so an array of plain hex strings yields undefined for every entry, is skipped, and the roster arrives EMPTY. config.js normalizeMembers exists for exactly this and is applied in loadConfig — so every node that boots through it is fine, and this app, which hand-parsed config.json, was the one reader that skipped the rule. The room then refused to project, logging projectMembers REFUSED: empty roster would wipe 3 existing member(s) every 30s, while the phone's config plainly listed three members. The guard was right; the shape was lost on the way in. coowner.js already documents this trap for its own raw reader; this was a third. The roster "repair" added an hour ago goes with it: it read the config's members, correctly found them non-empty, and did nothing — it was aimed at the wrong roster. Left in place it would now be worse than useless, since `cfg` is a DERIVED object and writing it back would overwrite the config with loadConfig's merged shape. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
d233237de07b85b61f9acb778d769b0c2b48650b
by plankatron <fleeky@gmail.com> · 2026-08-12T01:25:23.000Z

Changed files (4) +16 -112

lib/node.js +15 -32
……@@ -80,7 +80,18 @@ async function boot () {
8080 if (fs.existsSync(cfgPath)) {
8181 try {
8282 const { bootMultiDevice } = await import('hyper-proto-peer/lib/device-node.js')
83 const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'))
83 // ⛔ hpp's OWN LOADER, not JSON.parse. The config's `members` roster has three on-disk shapes —
84 // [{key,role}], an array of hex strings, or a "hex,hex" comma-string — and only the first is
85 // understood downstream: projectMembers reads `m.key`, so plain strings yield undefined, are
86 // skipped, and the roster arrives EMPTY. loadConfig normalises all three (config.js
87 // normalizeMembers), which is why every node that boots through it works and this app did not:
88 // hand-parsing made it the one reader that skipped the rule. The room then refused to project,
89 // logging "empty roster would wipe 3 existing member(s)" every 30s while the config listed three.
90 //
91 // loadConfig reads PEER_CONFIG, which is set above, and also applies the PEER_* env overrides —
92 // none of which are set on a handset, so this is the same file, correctly shaped.
93 const { loadConfig } = await import('hyper-proto-peer/lib/config.js')
94 const cfg = loadConfig()
8495 // deviceMnemonic is DEVICE-scoped: `coowner-join` mints it at config ROOT so a phone can generate
8596 // its key before it knows which DID it is joining, and the `dids` entry it writes carries none.
8697 // Not passing it is why a phone that had genuinely completed the ceremony served ZERO DIDs
……@@ -106,37 +117,9 @@ async function boot () {
106117 }
107118 } catch (e) { log('could not derive the device public key', { error: (e && e.message) || String(e) }) }
108119 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, rosterKeys } = await import('./roster.js')
120 const entry0 = (cfg.dids || [])[0]
121 const node0 = state.hpp && state.hpp.nodes && state.hpp.nodes[0]
122 // SAY WHY IT DECLINED. The first cut logged only on success and only on throw, so when it did
123 // neither it was indistinguishable from not having run — the exact failure mode (a branch that
124 // refuses silently) that made the write-path 500 take an evening to localise. A repair that
125 // skips must report which condition stopped it.
126 const can = !!(node0 && node0.device && typeof node0.device.membersDiag === 'function')
127 log('local roster check', { entryPresent: !!entry0, localMembers: rosterKeys(entry0).length, canReadRoom: can })
128 if (entry0 && rosterIsEmpty(entry0) && can) {
129 const md = await node0.device.membersDiag()
130 const inView = Object.keys((md && md.membersInView) || {})
131 log('room reports members', { inView: inView.length })
132 if (inView.length) {
133 fs.copyFileSync(cfgPath, cfgPath + '.bak.' + Date.now())
134 entry0.members = inView
135 fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n')
136 log('local roster was empty — adopted the ' + inView.length + ' member(s) the room already lists', { members: inView.length })
137 }
138 }
139 } catch (e) { log('could not repair the local roster (harmless; the room still gates admission)', { error: (e && e.message) || String(e) }) }
120 // (A roster "repair" lived here briefly — it read the config's members, found them non-empty, and
121 // correctly did nothing, because the empty roster was never the config's: it was the SHAPE being
122 // dropped on the way in, which loadConfig above now prevents. Removed rather than left dormant.)
140123 // ⛔ REPORT WHAT BOOTED, NOT WHAT WAS CONFIGURED. This line used to print the CONFIG's DID count,
141124 // so it said "signing enabled {dids:1}" while zero nodes had come up — the compose button renders
142125 // on `signing`, so the app looked paired and an hour went into diagnosing an empty appview when
lib/roster.js +0 -34
……@@ -1,34 +0,0 @@
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/roster.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/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 +0 -45
……@@ -1,45 +0,0 @@
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')