| … | … | @@ -80,7 +80,18 @@ async function boot () { |
| 80 | 80 | if (fs.existsSync(cfgPath)) { |
| 81 | 81 | try { |
| 82 | 82 | 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() | |
| 84 | 95 | // deviceMnemonic is DEVICE-scoped: `coowner-join` mints it at config ROOT so a phone can generate |
| 85 | 96 | // its key before it knows which DID it is joining, and the `dids` entry it writes carries none. |
| 86 | 97 | // Not passing it is why a phone that had genuinely completed the ceremony served ZERO DIDs |
| … | … | @@ -106,37 +117,9 @@ async function boot () { |
| 106 | 117 | } |
| 107 | 118 | } catch (e) { log('could not derive the device public key', { error: (e && e.message) || String(e) }) } |
| 108 | 119 | 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.) | |
| 140 | 123 | // ⛔ REPORT WHAT BOOTED, NOT WHAT WAS CONFIGURED. This line used to print the CONFIG's DID count, |
| 141 | 124 | // so it said "signing enabled {dids:1}" while zero nodes had come up — the compose button renders |
| 142 | 125 | // on `signing`, so the app looked paired and an hour went into diagnosing an empty appview when |
| … | … | @@ -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 | */ | |
| 18 | export 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 | */ | |
| 32 | export function rosterIsEmpty (entry) { | |
| 33 | return rosterKeys(entry).length === 0 | |
| 34 | } |
| … | … | @@ -9,7 +9,7 @@ |
| 9 | 9 | "pack:worklet": "bare-pack --preset android --defer bare-abstract-socket --defer pgh-runtime --out android/app/src/main/assets/worklet.bundle lib/node.js", |
| 10 | 10 | "stage:addons": "node scripts/stage-addons.mjs", |
| 11 | 11 | "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", | |
| 13 | 13 | "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))\"" |
| 14 | 14 | }, |
| 15 | 15 | "dependencies": { |
| … | … | @@ -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 | |
| 18 | import { rosterKeys, rosterIsEmpty } from '../lib/roster.js' | |
| 19 | ||
| 20 | const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m) } | |
| 21 | const A = 'a'.repeat(64) | |
| 22 | const B = 'b'.repeat(64) | |
| 23 | ||
| 24 | console.log('roster shapes') | |
| 25 | assert(rosterKeys({ members: [A, B] }).length === 2, 'array of hex strings') | |
| 26 | assert(rosterKeys({ members: [{ key: A, role: 1 }, { key: B, role: 1 }] }).length === 2, 'array of {key,role} objects') | |
| 27 | assert(rosterKeys({ members: A + ',' + B }).length === 2, 'COMMA-SEPARATED STRING — the production shape') | |
| 28 | assert(rosterKeys({ members: ' ' + A + ' , ' + B + ' ' })[0] === A, 'whitespace around string entries is trimmed') | |
| 29 | assert(rosterKeys({ members: [{ key: A }] })[0] === A, 'object entries yield their key, not [object Object]') | |
| 30 | assert(rosterKeys({}).length === 0, 'absent roster') | |
| 31 | assert(rosterKeys({ members: [] }).length === 0, 'empty array') | |
| 32 | assert(rosterKeys({ members: '' }).length === 0, 'empty string') | |
| 33 | assert(rosterKeys(null).length === 0, 'null entry') | |
| 34 | assert(rosterKeys({ members: [A.toUpperCase()] })[0] === A, 'normalised to lower case, so comparisons hold') | |
| 35 | ||
| 36 | console.log('emptiness — the decision that gates a WRITE') | |
| 37 | assert(rosterIsEmpty({}) === true, 'absent roster is empty') | |
| 38 | assert(rosterIsEmpty({ members: [] }) === true, 'empty array is empty') | |
| 39 | assert(rosterIsEmpty({ members: '' }) === true, 'empty string is empty') | |
| 40 | assert(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 | |
| 42 | assert(rosterIsEmpty({ members: A + ',' + B }) === false, | |
| 43 | 'a populated COMMA-STRING roster is NOT empty — the check that keeps this from wiping a fleet') | |
| 44 | ||
| 45 | console.log('\nall roster tests passed') |