| … | … | @@ -60,6 +60,20 @@ const START_INDEXING = '__start_indexing__' |
| 60 | 60 | // drops it from __members__, and removeWriters its writer(s) — so it can neither write now nor |
| 61 | 61 | // re-admit later (the Keet handleRemoveMember + add-guard mechanism). Never inserted as an entry. |
| 62 | 62 | const REMOVE_DEVICE = '__remove_device__' |
| 63 | // Reserved op key: ADMIT a member into the room, authored by an EXISTING role-1 member. Value = | |
| 64 | // JSON { id: <keet identity hex>, role }. This is keet's addDevice/acceptJoinRequest shape — the room | |
| 65 | // decides who belongs, and an existing member writes that decision INTO the room. | |
| 66 | // | |
| 67 | // It replaces the only mechanism we had: `projectMembers`, which reprojects a roster read from the | |
| 68 | // CREATOR's local config file. That made membership a per-machine fact — a co-owner could not admit | |
| 69 | // anyone (tabertron sits at rosterLen:0, membersProjected:false and is structurally unable to help), | |
| 70 | // and growing a roster on the wrong box did nothing at all (hyper-social #40 §6). Authorization is the | |
| 71 | // AUTHOR's role verified in apply, exactly like REMOVE_DEVICE below, so a non-member cannot use it to | |
| 72 | // admit itself or anyone else. | |
| 73 | // | |
| 74 | // A BANNED identity is NOT re-admitted by this op — REMOVED_SET stays authoritative, or a ban would be | |
| 75 | // reversible by any member simply re-adding the target. Un-banning is deliberately a separate act. | |
| 76 | const ADD_MEMBER = '__add_member__' | |
| 63 | 77 | // Durable set of banned device identities (hex → { ts }). The admission guard checks it so a |
| 64 | 78 | // removed identity cannot (re)admit even with a valid attestation. |
| 65 | 79 | const REMOVED_SET = '__removed__' |
| … | … | @@ -602,6 +616,21 @@ export class StateBaseV3 { |
| 602 | 616 | // Best-effort (see _isSelfRemoved): false on any uncertainty, so a non-banned device is never |
| 603 | 617 | // wrongly rejected — it just falls back to the normal doorbell. |
| 604 | 618 | if (await this._isSelfRemoved()) throw new Error('StateBaseV3: this device has been removed (banned) — cannot become a writer') |
| 619 | // DO NOT WRITE INTO A ROOM THAT DOES NOT LIST US. This is keet's rule (RoomFirewall.allow resolves | |
| 620 | // a peer through @keet/devices-by-swarm-key and admits only an ACTIVE device; membership is written | |
| 621 | // by an existing member via addDevice). An unlisted device asking to be let in is the 26,953-block | |
| 622 | // loop on wj62: apply resolves its role to null every time, so every append is futile, and the | |
| 623 | // doorbell re-rings on each base advance — including the advance its own append caused. | |
| 624 | // | |
| 625 | // POSITIVE EVIDENCE OF EXCLUSION ONLY. `_isSelfListed` returns null when the room has no roster in | |
| 626 | // our view at all — which is a fresh joiner mid-replication as much as it is an outsider, and those | |
| 627 | // two are indistinguishable from here. Refusing on absence broke a genuine member whose __members__ | |
| 628 | // had not landed yet (test/doorbell-durable-write.test.js: its set() threw instead of waiting), so | |
| 629 | // absence must mean "no information" and ring as before. Only a roster that EXISTS and omits us is | |
| 630 | // a refusal — which is the flooding node's state exactly: long converged, listed nobody's business, | |
| 631 | // 26,953 blocks of asking. A device admitted later rings on the very next advance, because | |
| 632 | // __members__ changing IS an advance — event-driven, no timer. | |
| 633 | if ((await this._isSelfListed()) === false) throw new Error('StateBaseV3: this room lists members and this device is not among them — wait for a member to admit it (addMember)') | |
| 605 | 634 | const admit = await this._buildAdmissionProof() |
| 606 | 635 | if (!admit) throw new Error('StateBaseV3 cannot request writer: no membership/identity to build an admission proof') |
| 607 | 636 | // key MUST be a string (encoder always writes it; undefined crashes compact- |
| … | … | @@ -729,7 +758,7 @@ export class StateBaseV3 { |
| 729 | 758 | // removeWriter this device by its identity (atproto-base-REVOCATION §6). Harmless if the op is |
| 730 | 759 | // later denied — the key never becomes a writer so removeWriter no-ops; admit.identity is the |
| 731 | 760 | // caller's OWN attested id (it cannot claim another's). |
| 732 | if (rec.key !== REMOVE_DEVICE && rec.key !== SET_EPOCH) { | |
| 761 | if (rec.key !== REMOVE_DEVICE && rec.key !== ADD_MEMBER && rec.key !== SET_EPOCH) { | |
| 733 | 762 | try { |
| 734 | 763 | const a = typeof rec.admit === 'string' ? JSON.parse(rec.admit) : rec.admit |
| 735 | 764 | const idHex = a && a.identity ? a.identity : null |
| … | … | @@ -799,6 +828,22 @@ export class StateBaseV3 { |
| 799 | 828 | } |
| 800 | 829 | } |
| 801 | 830 | } |
| 831 | } else if (rec.key === ADD_MEMBER) { | |
| 832 | // ADMIT A MEMBER (keet contract/members addDevice): authorized by the AUTHOR's role, exactly | |
| 833 | // like REMOVE_DEVICE above. The room records who belongs; the joiner never writes to ask. | |
| 834 | const role = await this._verifyAdmissionInApply(fromKey, rec.admit, ctx.tx) | |
| 835 | if (role !== 1) { console.log('[state-base-v3] ADD-MEMBER DENIED from=%s role=%s', fromHex, String(role)); return } | |
| 836 | let target = null; let targetRole = 1 | |
| 837 | try { const v = this._dec(rec.value); if (v && typeof v === 'object') { target = String(v.id || ''); targetRole = Number(v.role) || 1 } else if (v) { target = String(v) } } catch {} | |
| 838 | if (!target) { console.log('[state-base-v3] ADD-MEMBER: no target'); return } | |
| 839 | // A ban outranks an add — otherwise any member could undo a removal by re-adding. | |
| 840 | if (await this._isRemoved(ctx.tx, target)) { console.log('[state-base-v3] ADD-MEMBER REFUSED: %s is in __removed__ (unban first)', target.slice(0, 12)); return } | |
| 841 | const mrec = await ctx.tx.get(ENTRIES, { key: '__members__' }) | |
| 842 | const members = mrec && mrec.value ? JSON.parse(mrec.value) : {} | |
| 843 | if (members[target] === targetRole || members[normKeyHex(target)] === targetRole) return // idempotent | |
| 844 | members[normKeyHex(target)] = targetRole | |
| 845 | await ctx.tx.insert(ENTRIES, { key: '__members__', value: JSON.stringify(members), ts: rec.ts || 0 }) | |
| 846 | console.log('[state-base-v3] ADD-MEMBER: %s admitted role=%s by %s', target.slice(0, 12), String(targetRole), fromHex) | |
| 802 | 847 | } else if (rec.key === SET_EPOCH) { |
| 803 | 848 | // Set the minimum attestation epoch (atproto-base-REVOCATION §3): only a role-1 member. After |
| 804 | 849 | // this, admission rejects any attestation whose epoch is below the receipt — re-attest |
| … | … | @@ -820,7 +865,7 @@ export class StateBaseV3 { |
| 820 | 865 | } |
| 821 | 866 | // Empty/null key = a pure admission request; MOVE_INDEXER / START_INDEXING / REMOVE_DEVICE = |
| 822 | 867 | // reserved ops. All ran their effect above; none write a game entry. |
| 823 | if (!rec.key || rec.key === MOVE_INDEXER || rec.key === START_INDEXING || rec.key === REMOVE_DEVICE || rec.key === SET_EPOCH) return | |
| 868 | if (!rec.key || rec.key === MOVE_INDEXER || rec.key === START_INDEXING || rec.key === REMOVE_DEVICE || rec.key === ADD_MEMBER || rec.key === SET_EPOCH) return | |
| 824 | 869 | const existing = await ctx.tx.get(ENTRIES, { key: rec.key }) |
| 825 | 870 | if (existing && typeof existing.ts === 'number' && existing.ts > (rec.ts || 0)) return |
| 826 | 871 | await ctx.tx.insert(ENTRIES, { key: rec.key, value: rec.value, ts: rec.ts || 0 }) |
| … | … | @@ -843,6 +888,18 @@ export class StateBaseV3 { |
| 843 | 888 | await this.base.update() |
| 844 | 889 | } |
| 845 | 890 | |
| 891 | // Admit a member into the room (keet contract/members addDevice). An EXISTING role-1 member authors | |
| 892 | // it; apply authorizes by THIS caller's role. That is the whole point: membership becomes a fact of | |
| 893 | // the ROOM, replicated to every device, instead of a roster in one machine's config file that only | |
| 894 | // the creator could project (hyper-social #40 §6). | |
| 895 | async addMember (identityHex, role = 1) { | |
| 896 | if (!this._ready) throw new Error('StateBaseV3 not ready') | |
| 897 | const admit = await this._buildAdmissionProof() | |
| 898 | if (!admit) throw new Error('addMember: no identity to authorize the admission') | |
| 899 | await this.base.append(encode('@state/set-entry', { key: ADD_MEMBER, value: this._enc({ id: String(identityHex), role: Number(role) || 1 }), admit, ts: Date.now() }), { optimistic: true }) | |
| 900 | await this._updateBounded() | |
| 901 | } | |
| 902 | ||
| 846 | 903 | // Set the minimum attestation epoch (atproto-base-REVOCATION §3). receiptHex = a keet receipt |
| 847 | 904 | // encoding the new minimum epoch (membership.encodeReceipt). Authorized by the caller's admit |
| 848 | 905 | // proof (role 1). After this, attestations below the epoch are rejected on admission. |
| … | … | @@ -883,6 +940,41 @@ export class StateBaseV3 { |
| 883 | 940 | } catch { return false } |
| 884 | 941 | } |
| 885 | 942 | |
| 943 | // Does the room LIST this device — i.e. could an admission actually succeed? Read against the | |
| 944 | // linearized view exactly like _isSelfRemoved. Two ways to be listed, matching what | |
| 945 | // _verifyAdmissionInApply consults: our keet identity in __members__, or one of our writer keys | |
| 946 | // mapped to a listed member in __devices__ (a device admitted earlier, now rebooting unwritable — | |
| 947 | // the Phase-2 case, which MUST keep ringing). | |
| 948 | // | |
| 949 | // TRI-STATE: true = listed, false = the roster exists and omits us (refuse), null = we cannot tell | |
| 950 | // yet (ring as before). Collapsing null into false is what broke a genuine member whose roster had | |
| 951 | // not replicated — its set() threw instead of waiting. Only positive evidence of exclusion silences | |
| 952 | // a device; that is precisely the flooding node's state and not a joiner's. | |
| 953 | async _isSelfListed () { | |
| 954 | try { | |
| 955 | if (!this._ready || !this.base) return null // unknown — not "no" | |
| 956 | await this._updateBounded() | |
| 957 | const mrec = await this.base.view.get(ENTRIES, { key: '__members__' }) | |
| 958 | // NULL, not false: no roster in OUR view is a fresh joiner mid-replication as often as it is an | |
| 959 | // outsider, and the caller must not treat the two alike. Only an existing roster that omits us | |
| 960 | // is a refusal. | |
| 961 | if (!mrec || !mrec.value) return null | |
| 962 | const members = JSON.parse(mrec.value) | |
| 963 | if (this._selfKey) { | |
| 964 | const hex = this._selfKey.toString('hex') | |
| 965 | if (members[hex] != null || members[normKeyHex(hex)] != null) return true | |
| 966 | } | |
| 967 | const drec = await this.base.view.get(ENTRIES, { key: '__devices__' }) | |
| 968 | const devmap = drec && drec.value ? JSON.parse(drec.value) : {} | |
| 969 | const localHex = this.base.local && this.base.local.key ? this.base.local.key.toString('hex') : null | |
| 970 | if (localHex) { | |
| 971 | const memberKey = devmap[localHex] != null ? devmap[localHex] : devmap[normKeyHex(localHex)] | |
| 972 | if (memberKey && (members[memberKey] != null || members[normKeyHex(memberKey)] != null)) return true | |
| 973 | } | |
| 974 | return false // the roster EXISTS and does not name us — the one case that is a real refusal | |
| 975 | } catch { return null } | |
| 976 | } | |
| 977 | ||
| 886 | 978 | // ── One-time in-place re-adopt migration (namespace rename) ───────────────── |
| 887 | 979 | // The 'hyper-tps/state/v3' -> 'atproto-room/state/v3' rename changes the derived base key, so a |
| 888 | 980 | // room created under the old namespace opens here as a FRESH, empty base that shares the same |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "atproto-room", |
| 3 | "version": "0.2.20", | |
| 3 | "version": "0.2.21", | |
| 4 | 4 | "description": "Attestation-gated, encrypted multi-writer room engine — StateBase (keet-model admission) + Lease + signer + RepoBase + LabelBase/LabelRoom + serve-membrane. Extracted from hyper-proto-peer; consumed by the atproto peer (repo devices) and the co-authored labeler.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -13,6 +13,7 @@ import './signer.test.js' |
| 13 | 13 | import './signer-conformance.test.js' |
| 14 | 14 | import './membership.test.js' |
| 15 | 15 | import './doorbell-durable-write.test.js' |
| 16 | import './doorbell-bounded.test.js' | |
| 16 | 17 | import './admission-members-projection.test.mjs' |
| 17 | 18 | import './devices-projection.test.js' |
| 18 | 19 | import './phase2-nonblocking.test.js' |
| … | … | @@ -0,0 +1,164 @@ |
| 1 | // An outsider must not write into the room to ask to be let in. | |
| 2 | // | |
| 3 | // Production, wj62 (hyper-social #40): a writer the room had REMOVED kept requesting admission and had | |
| 4 | // appended 26,953 blocks to a core that can never be admitted — still climbing across two reads minutes | |
| 5 | // apart, and accounting for HALF of every hpp daemon's log volume on both servers: | |
| 6 | // | |
| 7 | // writer 4d163d63c795 authored 26218 -> 26953 removed:true | |
| 8 | // tabertron: 1000 of the last 2000 log lines = ADMIT attempt from=4d163d63c795 (stortron: same) | |
| 9 | // | |
| 10 | // The loop feeds itself: `requestWriter` appends an optimistic doorbell block and re-appends while it | |
| 11 | // waits; `ensureAdmitted` is driven by the sign tick; the tick fires on every base advance — including | |
| 12 | // the advance the doorbell's own append caused. | |
| 13 | // | |
| 14 | // The fix is not a slower doorbell, it is keet's rule. `RoomFirewall.allow()` resolves an incoming | |
| 15 | // peer through `@keet/devices-by-swarm-key` and admits only an ACTIVE device; membership is written by | |
| 16 | // an EXISTING member via addDevice/acceptJoinRequest. An outsider never writes to the room at all, so | |
| 17 | // there is nothing to rate-limit. Two properties, and neither is about time: | |
| 18 | // | |
| 19 | // 1. a device the room does not list appends NOTHING, however often it is asked to try | |
| 20 | // 2. a device the room DOES list still rings — that is the Phase-2 case (an admitted writer whose | |
| 21 | // indexer never signed a checkpoint it persisted) and it must keep working | |
| 22 | // | |
| 23 | // Run: bare test/doorbell-bounded.test.js | |
| 24 | import '../lib/bare-globals.js' | |
| 25 | import test from 'brittle' | |
| 26 | import crypto from 'hypercore-crypto' | |
| 27 | import fs from 'bare-fs' | |
| 28 | import b4a from 'b4a' | |
| 29 | import createTestnet from '@hyperswarm/testnet' | |
| 30 | import { StateBaseV3 } from '../lib/state-base.js' | |
| 31 | import { makeAttest, makeVerifyAttest, attestDeviceChain, deviceKeetKeyPair, rootIdentityPublicKey } from '../lib/membership.js' | |
| 32 | ||
| 33 | const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' | |
| 34 | const DEVA = 'legal winner thank year wave sausage worth useful legal winner thank yellow' | |
| 35 | const DEVB = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong' | |
| 36 | const tmp = () => { const d = '/tmp/hpp-doorbell-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d } | |
| 37 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) | |
| 38 | const hex = (k) => b4a.toString(k, 'hex') | |
| 39 | const localLen = (sb) => (sb.base && sb.base.local ? sb.base.local.length : 0) | |
| 40 | // projectMembers is event-driven + self-terminating: it lands on a connection or a promote tick, not | |
| 41 | // on the await. A member cannot author anything before the room HAS a roster, so wait for the fact. | |
| 42 | const waitMembers = async (sb, ms = 90000) => { | |
| 43 | const end = Date.now() + ms | |
| 44 | while (Date.now() < end) { const m = await sb.getNow('__members__').catch(() => null); if (m && Object.keys(m).length) return m; await sleep(500) } | |
| 45 | return null | |
| 46 | } | |
| 47 | ||
| 48 | // A is the creator and the only member; B is attested by the same root but deliberately NOT listed — | |
| 49 | // the terminal state shared by the removed writer and by the phone (`verify: no __members__ in view`). | |
| 50 | async function room (testnet, { includeB = false } = {}) { | |
| 51 | const rootPub = await rootIdentityPublicKey(ROOT) | |
| 52 | const da = await deviceKeetKeyPair(DEVA); const pa = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: da.publicKey }) | |
| 53 | const db = await deviceKeetKeyPair(DEVB); const pb = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: db.publicKey }) | |
| 54 | const members = [{ key: hex(da.publicKey), role: 1 }] | |
| 55 | if (includeB) members.push({ key: hex(db.publicKey), role: 1 }) | |
| 56 | const roomSeed = crypto.randomBytes(32); const roomKey = crypto.randomBytes(32); const encryptionKey = crypto.randomBytes(32) | |
| 57 | const mk = (dev, proof, extra = {}) => new StateBaseV3(tmp(), roomKey, { | |
| 58 | roomSeed, encryptionKey, swarmOpts: { bootstrap: testnet.bootstrap }, | |
| 59 | selfKey: dev.publicKey, attest: makeAttest(dev, proof), verifyAttest: makeVerifyAttest(rootPub), | |
| 60 | getMembers: async () => members, ...extra | |
| 61 | }) | |
| 62 | return { da, pa, db, pb, members, mk } | |
| 63 | } | |
| 64 | ||
| 65 | test('a device the room does not list appends NOTHING', { timeout: 300000 }, async (t) => { | |
| 66 | const testnet = await createTestnet(3) | |
| 67 | t.teardown(() => testnet.destroy()) | |
| 68 | const { da, pa, db, pb, mk, members } = await room(testnet) | |
| 69 | ||
| 70 | const a = mk(da, pa, { forceCreator: true }) | |
| 71 | await a.ready(); t.teardown(async () => { try { await a.close() } catch {} }) | |
| 72 | await a.projectMembers(members).catch(() => {}) // explicit: projectMembers(null) only re-projects a RETAINED roster | |
| 73 | ||
| 74 | const b = mk(db, pb, { joiner: true }) | |
| 75 | await b.ready(); t.teardown(async () => { try { await b.close() } catch {} }) | |
| 76 | t.absent(b.base.writable, 'precondition: B is not admitted') | |
| 77 | // B must have CONVERGED the roster and be absent from it — that is the flooding node's real state | |
| 78 | // (long-lived, once a writer, now excluded). An unconverged view is "no information", not a refusal. | |
| 79 | t.ok(await waitMembers(b), 'B has replicated the roster (and is not in it)') | |
| 80 | ||
| 81 | const before = localLen(b) | |
| 82 | for (let i = 0; i < 25; i++) { await b.ensureAdmitted({ timeoutMs: 1000 }).catch(() => {}); await sleep(20) } | |
| 83 | const appended = localLen(b) - before | |
| 84 | ||
| 85 | t.absent(b.base.writable, 'still not admitted — nothing here grants membership') | |
| 86 | t.is(appended, 0, 'it wrote NOTHING into the room (25 attempts; production wrote 26,953 blocks)') | |
| 87 | }) | |
| 88 | ||
| 89 | test('a member-authored addMember lets the joiner in — the room decides, not the outsider', { timeout: 300000 }, async (t) => { | |
| 90 | const testnet = await createTestnet(3) | |
| 91 | t.teardown(() => testnet.destroy()) | |
| 92 | const { da, pa, db, pb, mk, members } = await room(testnet) | |
| 93 | ||
| 94 | const a = mk(da, pa, { forceCreator: true }) | |
| 95 | await a.ready(); t.teardown(async () => { try { await a.close() } catch {} }) | |
| 96 | await a.projectMembers(members).catch(() => {}) // explicit: projectMembers(null) only re-projects a RETAINED roster | |
| 97 | ||
| 98 | const b = mk(db, pb, { joiner: true }) | |
| 99 | await b.ready(); t.teardown(async () => { try { await b.close() } catch {} }) | |
| 100 | ||
| 101 | await b.ensureAdmitted({ timeoutMs: 1000 }).catch(() => {}) | |
| 102 | t.absent(b.base.writable, 'B cannot let itself in') | |
| 103 | ||
| 104 | t.ok(await waitMembers(a), 'the creator has projected its roster (a member can only author once the room has one)') | |
| 105 | // The EXISTING member authors the membership — keet's addDevice, not a request from B. | |
| 106 | await a.addMember(hex(db.publicKey), 1) | |
| 107 | ||
| 108 | let admitted = false | |
| 109 | const deadline = Date.now() + 120000 | |
| 110 | while (Date.now() < deadline && !admitted) { | |
| 111 | await b.ensureAdmitted({ timeoutMs: 5000 }).catch(() => {}) | |
| 112 | admitted = !!b.base.writable | |
| 113 | if (!admitted) await sleep(500) | |
| 114 | } | |
| 115 | t.ok(admitted, 'once the room lists B, B is admitted — the doorbell still works for a real member') | |
| 116 | ||
| 117 | const seen = await b.getNow('__members__') | |
| 118 | t.ok(seen && seen[hex(db.publicKey)] != null, 'and B appears in __members__ on B\'s own replica') | |
| 119 | }) | |
| 120 | ||
| 121 | test('a non-member cannot author a membership', { timeout: 300000 }, async (t) => { | |
| 122 | // The authorization is the AUTHOR's role, verified in apply — the same gate REMOVE_DEVICE uses. | |
| 123 | // Without it, "the room decides" would just move the free-for-all one level up. | |
| 124 | const testnet = await createTestnet(3) | |
| 125 | t.teardown(() => testnet.destroy()) | |
| 126 | const { da, pa, db, pb, mk, members } = await room(testnet) | |
| 127 | ||
| 128 | const a = mk(da, pa, { forceCreator: true }) | |
| 129 | await a.ready(); t.teardown(async () => { try { await a.close() } catch {} }) | |
| 130 | await a.projectMembers(members).catch(() => {}) // explicit: projectMembers(null) only re-projects a RETAINED roster | |
| 131 | ||
| 132 | const b = mk(db, pb, { joiner: true }) | |
| 133 | await b.ready(); t.teardown(async () => { try { await b.close() } catch {} }) | |
| 134 | ||
| 135 | const victim = hex(crypto.randomBytes(32)) | |
| 136 | await b.addMember(victim, 1).catch(() => {}) // B is not a member — must not take effect | |
| 137 | await sleep(3000) | |
| 138 | ||
| 139 | const onA = await a.getNow('__members__') | |
| 140 | t.absent(onA && onA[victim] != null, 'the creator\'s view did NOT gain the member a non-member proposed') | |
| 141 | }) | |
| 142 | ||
| 143 | test('an admitted device still rings — the Phase-2 reboot case is preserved', { timeout: 300000 }, async (t) => { | |
| 144 | // A writer admitted at runtime can reboot writable=false when no indexer signed a checkpoint it | |
| 145 | // persisted. It IS listed, so it must still ring; gating on "listed" must not break this. | |
| 146 | const testnet = await createTestnet(3) | |
| 147 | t.teardown(() => testnet.destroy()) | |
| 148 | const { da, pa, db, pb, mk, members } = await room(testnet, { includeB: true }) | |
| 149 | ||
| 150 | const a = mk(da, pa, { forceCreator: true }) | |
| 151 | await a.ready(); t.teardown(async () => { try { await a.close() } catch {} }) | |
| 152 | await a.projectMembers(members).catch(() => {}) // explicit: projectMembers(null) only re-projects a RETAINED roster | |
| 153 | ||
| 154 | const b = mk(db, pb, { joiner: true }) | |
| 155 | await b.ready(); t.teardown(async () => { try { await b.close() } catch {} }) | |
| 156 | ||
| 157 | t.ok(await waitMembers(a), 'the roster is projected and lists B') | |
| 158 | const deadline = Date.now() + 120000 | |
| 159 | while (Date.now() < deadline && !b.base.writable) { await b.ensureAdmitted({ timeoutMs: 5000 }).catch(() => {}); await sleep(400) } | |
| 160 | // Becoming writable IS the proof that it rang: admission requires an admit op from B, so a gate that | |
| 161 | // wrongly silenced a listed device would leave it read-only forever. (base.local is the wrong probe — | |
| 162 | // an optimistic block is not there until it is acked.) | |
| 163 | t.ok(b.base.writable, 'a LISTED device still rings and is admitted — the Phase-2 reboot case survives') | |
| 164 | }) |