| … | … | @@ -0,0 +1,104 @@ |
| 1 | // Admission ⇄ __members__ projection: reproduce the wj62 fork's ROOT and prove the fix mechanism. |
| 2 | // |
| 3 | // Diagnosis (pear-git #4 / hyper-social #9 lineage): a member is admitted by the CREATOR's in-apply |
| 4 | // verify (_verifyAdmissionInApply), which reads __members__ from the creator's VIEW. __members__ is |
| 5 | // written ONLY by the creator's projectMembers(), which no-ops unless base.writable and — in the app |
| 6 | // (device-node) — is driven by a BOUNDED boot loop (12 tries / ~13s) with NO re-projection afterward. |
| 7 | // If the creator's writability / projection lands after that window (re-adopt + swarm flap in prod), |
| 8 | // __members__ is never in the view, every member admit verifies verifiedRole=null (state-base.js:990 |
| 9 | // "no __members__ in view"), the member is never admitted, and it holds its own line → the fork. |
| 10 | // |
| 11 | // A = bug repro: creator UP but __members__ NOT projected ⇒ member admission REJECTED, stays read-only. |
| 12 | // B = fix mechanism: a LATER (event-driven) projection + the member's existing re-ring ⇒ admitted. |
| 13 | // This is exactly what an on-writable-transition re-projection buys — the give-up TIMER is the bug, |
| 14 | // the event-driven re-projection is the fix; the member side already re-rings (requestWriter). |
| 15 | |
| 16 | import '../lib/bare-globals.js' |
| 17 | import test from 'brittle' |
| 18 | import crypto from 'hypercore-crypto' |
| 19 | import fs from 'bare-fs' |
| 20 | import b4a from 'b4a' |
| 21 | import createTestnet from '@hyperswarm/testnet' |
| 22 | import { StateBaseV3 } from '../lib/state-base.js' |
| 23 | import { makeAttest, makeVerifyAttest, attestDeviceChain, deviceKeetKeyPair, rootIdentityPublicKey } from '../lib/membership.js' |
| 24 | |
| 25 | const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' |
| 26 | const DEVA = 'legal winner thank year wave sausage worth useful legal winner thank yellow' |
| 27 | const DEVB = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong' |
| 28 | const tmp = () => { const d = '/tmp/hpp-adm-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d } |
| 29 | const sleep = (ms) => new Promise(r => setTimeout(r, ms)) |
| 30 | |
| 31 | async function setup (testnet) { |
| 32 | const rootPub = await rootIdentityPublicKey(ROOT) |
| 33 | const da = await deviceKeetKeyPair(DEVA); const pa = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: da.publicKey }) |
| 34 | const db = await deviceKeetKeyPair(DEVB); const pb = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: db.publicKey }) |
| 35 | let A = { dev: da, proof: pa }, B = { dev: db, proof: pb } |
| 36 | if (b4a.compare(A.dev.publicKey, B.dev.publicKey) > 0) { const t = A; A = B; B = t } // creator = lowest key |
| 37 | const members = [ |
| 38 | { key: b4a.toString(A.dev.publicKey, 'hex'), role: 1 }, |
| 39 | { key: b4a.toString(B.dev.publicKey, 'hex'), role: 1 } |
| 40 | ] |
| 41 | const roomSeed = crypto.randomBytes(32); const roomKey = crypto.randomBytes(32); const encryptionKey = crypto.randomBytes(32) |
| 42 | const mk = (dev, proof) => new StateBaseV3(tmp(), roomKey, { |
| 43 | roomSeed, encryptionKey, swarmOpts: { bootstrap: testnet.bootstrap }, |
| 44 | selfKey: dev.publicKey, attest: makeAttest(dev, proof), verifyAttest: makeVerifyAttest(rootPub), |
| 45 | getMembers: async () => members |
| 46 | }) |
| 47 | return { A, B, members, mk } |
| 48 | } |
| 49 | |
| 50 | // A — THE BUG: creator up, __members__ never projected ⇒ member can't be admitted (the fork precondition). |
| 51 | test('A: no __members__ in the creator view ⇒ member admission REJECTED, stays read-only', { timeout: 120000 }, async (t) => { |
| 52 | const testnet = await createTestnet(3) |
| 53 | const { members, mk, A, B } = await setup(testnet) |
| 54 | const sbA = mk(A.dev, A.proof); await sbA.ready() // creator UP + writable, but we DELIBERATELY never projectMembers |
| 55 | const sbB = mk(B.dev, B.proof); await sbB.ready() |
| 56 | t.teardown(async () => { try { await sbA.close(); await sbB.close() } catch {} ; await testnet.destroy() }) |
| 57 | |
| 58 | t.ok(sbA.base.writable, 'creator is writable (bootstrap indexer) — writability is NOT the miss') |
| 59 | t.absent(await sbA.get('__members__'), 'creator view has NO __members__ (the give-up-timer state: projection never landed)') |
| 60 | |
| 61 | // Member rings the doorbell. With no __members__, the creator verifies verifiedRole=null and never |
| 62 | // addWriters → member stays read-only. Bounded so the test doesn't hang. |
| 63 | const r = await sbB.requestWriter({ timeoutMs: 8000 }) |
| 64 | t.absent(r.writable, 'requestWriter returned writable:false — the member was NOT admitted') |
| 65 | t.absent(sbB.base.writable, 'member base stays READ-ONLY (would hold its own divergent line in prod)') |
| 66 | }) |
| 67 | |
| 68 | // B — THE FIX MECHANISM: a LATER projection (what an on-writable-transition re-projection fires) + |
| 69 | // the member's existing re-ring ⇒ admission succeeds where the bounded timer had given up. |
| 70 | test('B: late __members__ projection + member re-ring ⇒ ADMITTED and converges (event-driven fix)', { timeout: 120000 }, async (t) => { |
| 71 | const testnet = await createTestnet(3) |
| 72 | const { members, mk, A, B } = await setup(testnet) |
| 73 | const sbA = mk(A.dev, A.proof); await sbA.ready() // creator up, projection intentionally NOT done yet |
| 74 | const sbB = mk(B.dev, B.proof); await sbB.ready() |
| 75 | t.teardown(async () => { try { await sbA.close(); await sbB.close() } catch {} ; await testnet.destroy() }) |
| 76 | |
| 77 | // (1) reproduce the stuck state: member cannot be admitted while __members__ is absent. |
| 78 | const stuck = await sbB.requestWriter({ timeoutMs: 6000 }) |
| 79 | t.absent(stuck.writable, 'pre-projection: member is stuck read-only (the bug)') |
| 80 | |
| 81 | // (2) the fix: the creator projects __members__ LATER — this is exactly what a re-projection hooked |
| 82 | // to the writable-transition (instead of the one-shot boot loop) would do once writability settles. |
| 83 | await sbA.projectMembers(members) |
| 84 | t.ok(await sbA.get('__members__'), 'creator view now HAS __members__ (event-driven re-projection landed)') |
| 85 | |
| 86 | // (3) the member's existing re-ring (as the sign-tick's ensureAdmitted re-fires) now gets admitted. |
| 87 | const admitted = await sbB.requestWriter({ timeoutMs: 30000 }) |
| 88 | t.ok(admitted.writable, 'post-projection: member is ADMITTED (re-ring succeeded where the timer gave up)') |
| 89 | t.ok(sbB.base.writable, 'member base is now writable — one line, not two') |
| 90 | |
| 91 | // (4) prove it is a genuine single line: member writes, creator converges to the same record. |
| 92 | // NB: admission can be OPTIMISTICALLY writable before the indexer FINALIZES it (pear-git #4 Surprise |
| 93 | // #3) — a durable set() in that window throws "Not writable". Drive updates + retry until the |
| 94 | // admission finalizes and the write sticks (this is what the app's sign-tick re-fire does in prod). |
| 95 | let wrote = false |
| 96 | for (let i = 0; i < 30 && !wrote; i++) { |
| 97 | try { await sbB.set('post/1', { text: 'admitted-after-late-projection' }); wrote = true } catch { |
| 98 | await sbA.base.update().catch(() => {}); await sbB.base.update().catch(() => {}); await sleep(300) |
| 99 | } |
| 100 | } |
| 101 | t.ok(wrote, 'member durably wrote once admission FINALIZED (optimistic-writable gap, pear-git #4 §3)') |
| 102 | for (let i = 0; i < 20 && !(await sbA.get('post/1')); i++) { await sbA.base.update().catch(() => {}); await sbB.base.update().catch(() => {}); await sleep(300) } |
| 103 | t.alike(await sbA.get('post/1'), { text: 'admitted-after-late-projection' }, 'creator converged with the admitted member — ONE line, no fork') |
| 104 | }) |