OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer commits / 0d6322d

normalise the roster where it is consumed, not in each reader
`members` is written in three shapes — [{key,role}], an array of hex strings (coownerJoin), and a "hex,hex" comma-string (wj62 in production) — but projectMembers understands only the first: it reads `m.key`, so plain strings yield undefined, every entry is skipped, and the roster arrives EMPTY. That failure is silent by design. The room refuses to project an empty roster rather than wipe real members, so the device simply stays unlisted while its own config plainly names members, and the only symptom is a log line every 30s. favela-android hit it by parsing config.json itself instead of using loadConfig, which is where the normalisation had been living. This is the third instance of the same trap here (coowner.js documents its own), so fix it at the boundary that CONSUMES the value instead of asking every reader to remember the rule. Idempotent for already-normalised input, which is what every node booting through loadConfig passes today — so the fleet is unaffected; it only adds correct handling for the two shapes that currently vanish. normalizeMembers is exported for this (visibility only, no logic change). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0d6322d31c76dd814db7a1dc481774752c557db9
by plankatron <fleeky@gmail.com> · 2026-08-12T02:07:14.000Z

Changed files (3) +54 -2

lib/config.js +1 -1
……@@ -31,7 +31,7 @@ function normalizeWriters (raw) {
3131
3232// Normalize the device roster to [{ key: <hex>, role: 1 }] (all devices role-equal). Accepts a
3333// comma-string of hex pubkeys or an array of hex strings / {key,role} objects.
34function normalizeMembers (raw) {
34export function normalizeMembers (raw) {
3535 if (!raw) return null
3636 const list = Array.isArray(raw) ? raw : String(raw).split(',')
3737 const out = []
lib/device-node.js +11 -1
……@@ -36,6 +36,7 @@ import { openCorestore } from 'atproto-store/lib/corestore.js'
3636import { watchStore } from './conflict-watch.js'
3737import { AccountRegistry } from 'atproto-store/lib/account-registry.js'
3838import { loadOrCreateSwarmKeyPair } from './swarm-keypair.js'
39import { normalizeMembers } from './config.js'
3940import { makeSessions } from './sessions.js'
4041import { readCar, loadCommit, listRecords } from 'atproto-repo/lib/repo.js'
4142
……@@ -129,7 +130,16 @@ export async function bootSwarmDevice ({ storage, deviceMnemonic, did, signingKe
129130 // this device's OWN keet identity + the root→device attestation it was provisioned with
130131 const deviceKp = await deviceKeetKeyPair(deviceMnemonic)
131132 const self = b4a.toString(deviceKp.publicKey, 'hex')
132 const roster = members || [{ key: self, role: 1 }]
133 // NORMALISE AT THE BOUNDARY, do not trust the caller to have done it. `members` reaches here in three
134 // shapes — [{key,role}], an array of hex strings, or a "hex,hex" comma-string — and projectMembers
135 // understands only the first (state-base.js reads `m.key`, so a plain string yields undefined and is
136 // skipped, producing an EMPTY map). loadConfig normalises, so every node booting through it is fine;
137 // a caller that parses config.json itself is not. favela-android did exactly that, and its roster
138 // silently evaporated: the room refused to project, logging "empty roster would wipe 3 existing
139 // member(s)" every 30s while its config plainly listed three. Third instance of this trap in this
140 // codebase (see coowner.js) — hence fixing it where the value is CONSUMED, not in each reader.
141 // Idempotent for already-normalised input, which is what every fleet node passes today.
142 const roster = normalizeMembers(members) || [{ key: self, role: 1 }]
133143
134144 // ── PRE-CUTOVER DID: keep the StateBase WHERE IT ALREADY IS ──────────────────────────────────────
135145 // `primary` already means "this DID predates the multi-DID cutover on this box, keep its paths
test/roster-normalize.test.mjs +42 -0
……@@ -0,0 +1,42 @@
1// normalizeMembers — the roster shape rule, at the boundary that consumes it.
2//
3// WHY THIS IS A TEST AND NOT A COMMENT. `members` is written to config.json in three shapes:
4// [{key,role}], an array of hex strings (what coownerJoin writes), and a "hex,hex" comma-string (what
5// wj62 carries in production). Downstream, projectMembers (atproto-room/lib/state-base.js) reads
6// `m.key` — so the two string shapes yield undefined for every entry, are skipped, and the roster
7// arrives EMPTY. An empty roster is not a loud failure: the room quietly refuses to project it
8// ("empty roster would wipe N existing member(s)"), so a device stays unlisted while its own config
9// plainly names members. That is a whole evening of debugging, and it has now happened three times in
10// this codebase (coowner.js documents its own instance).
11//
12// So bootSwarmDevice normalises what it is HANDED rather than trusting each caller to have used
13// loadConfig. These assertions pin that rule.
14//
15// Run: bare test/roster-normalize.test.mjs
16import { normalizeMembers } from '../lib/config.js'
17
18const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m) }
19const A = 'a'.repeat(64)
20const B = 'b'.repeat(64)
21const keys = (r) => (r || []).map((m) => m.key)
22
23console.log('the three shapes all reach one form')
24assert(JSON.stringify(normalizeMembers([{ key: A, role: 1 }])) === JSON.stringify([{ key: A, role: 1 }]),
25 'already-normalised input is returned unchanged — idempotent, so every fleet node is unaffected')
26assert(JSON.stringify(keys(normalizeMembers([A, B]))) === JSON.stringify([A, B]),
27 'ARRAY OF HEX STRINGS — what coownerJoin writes, and what silently emptied the phone roster')
28assert(JSON.stringify(keys(normalizeMembers(A + ',' + B))) === JSON.stringify([A, B]),
29 'COMMA-STRING — the shape wj62 carries in production')
30assert(normalizeMembers([{ key: A }])[0].role === 1, 'role defaults to 1 (all devices role-equal)')
31assert(normalizeMembers([A.toUpperCase()])[0].key === A, 'normalised to lower case, so key comparisons hold')
32assert(normalizeMembers([' ' + A + ' '])[0].key === A, 'entries are trimmed')
33
34console.log('absence stays absence — never an empty roster')
35// null (not []) matters: bootSwarmDevice falls back to [{key:self,role:1}] on a falsy roster, and an
36// empty ARRAY would instead project "no members", which is the wipe the room has to defend against.
37assert(normalizeMembers(null) === null, 'null in, null out')
38assert(normalizeMembers([]) === null, 'an empty array yields null, NOT an empty roster')
39assert(normalizeMembers('') === null, 'an empty string yields null')
40assert(normalizeMembers(['', ' ']) === null, 'entries that are all blank yield null, not [{key:""}]')
41
42console.log('\nall roster-normalize tests passed')