OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-room commits / 3784d50

0.2.21: the room decides who belongs — addMember, and outsiders stop asking
wj62 carried a writer the room had REMOVED which kept requesting admission: 26,953 blocks appended to a core that can never be admitted, still climbing, and HALF of every hpp daemon's log volume on both servers (1000 of the last 2000 lines on tabertron AND stortron — it was never phone-specific, just never audible). The loop feeds itself. requestWriter appends an optimistic doorbell block and re-appends while it waits; ensureAdmitted runs off the sign tick; the tick fires on every base advance — including the advance the doorbell's own append caused. The fix is keet's rule, not a slower doorbell. RoomFirewall.allow() resolves a peer through @keet/devices-by-swarm-key and admits only an ACTIVE device, and membership is written by an EXISTING member (addDevice/acceptJoinRequest) — an outsider never writes to the room, so there is nothing to rate-limit and no timer anywhere. ADD_MEMBER: an existing role-1 member admits another, authorized in apply by the AUTHOR's role — the same gate REMOVE_DEVICE already uses, of which this is the inverse. Membership becomes a fact of the ROOM rather than a roster in one machine's config that only the creator could project (tabertron sits at rosterLen:0/membersProjected:false and was structurally unable to admit anyone — hyper-social #40 §6). A banned identity is NOT re-admitted by it; REMOVED_SET stays authoritative or a ban would be reversible by anyone. requestWriter now refuses to write when the room lists members and this device is not among them. TRI-STATE, and that part matters: absence of a roster in OUR view is null — "cannot tell yet" — because a fresh joiner mid-replication looks identical to an outsider from here. Collapsing that into "no" regressed doorbell-durable-write (a genuine member's set() threw instead of waiting), which corrects my own claim that this could not change who gets in: it can, and only positive evidence of exclusion is safe. That is exactly the flooding node's state — long converged, roster present, itself absent. test/doorbell-bounded.test.js, event-shaped with no timing assertions: an excluded device appends NOTHING across 25 attempts (control: 26,953 in production); a member-authored addMember admits a joiner; a non-member cannot author one; and a LISTED device still rings, preserving the Phase-2 reboot case. Suite 57/57, 214/214. Still open (deliberately not in this commit): coowner-invite must author ADD_MEMBER at invite time, and only then is RoomFirewall safe to add — denying unlisted peers before invites write the record would lock out legitimate joiners. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3784d5024f08d091fa9f7ebdc6aa79c85e378f1f
by plankatron <fleeky@gmail.com> · 2026-08-10T21:03:27.000Z

Changed files (4) +260 -3

lib/state-base.js +94 -2
……@@ -60,6 +60,20 @@ const START_INDEXING = '__start_indexing__'
6060// drops it from __members__, and removeWriters its writer(s) — so it can neither write now nor
6161// re-admit later (the Keet handleRemoveMember + add-guard mechanism). Never inserted as an entry.
6262const 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.
76const ADD_MEMBER = '__add_member__'
6377// Durable set of banned device identities (hex → { ts }). The admission guard checks it so a
6478// removed identity cannot (re)admit even with a valid attestation.
6579const REMOVED_SET = '__removed__'
……@@ -602,6 +616,21 @@ export class StateBaseV3 {
602616 // Best-effort (see _isSelfRemoved): false on any uncertainty, so a non-banned device is never
603617 // wrongly rejected — it just falls back to the normal doorbell.
604618 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)')
605634 const admit = await this._buildAdmissionProof()
606635 if (!admit) throw new Error('StateBaseV3 cannot request writer: no membership/identity to build an admission proof')
607636 // key MUST be a string (encoder always writes it; undefined crashes compact-
……@@ -729,7 +758,7 @@ export class StateBaseV3 {
729758 // removeWriter this device by its identity (atproto-base-REVOCATION §6). Harmless if the op is
730759 // later denied — the key never becomes a writer so removeWriter no-ops; admit.identity is the
731760 // 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) {
733762 try {
734763 const a = typeof rec.admit === 'string' ? JSON.parse(rec.admit) : rec.admit
735764 const idHex = a && a.identity ? a.identity : null
……@@ -799,6 +828,22 @@ export class StateBaseV3 {
799828 }
800829 }
801830 }
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)
802847 } else if (rec.key === SET_EPOCH) {
803848 // Set the minimum attestation epoch (atproto-base-REVOCATION §3): only a role-1 member. After
804849 // this, admission rejects any attestation whose epoch is below the receipt — re-attest
……@@ -820,7 +865,7 @@ export class StateBaseV3 {
820865 }
821866 // Empty/null key = a pure admission request; MOVE_INDEXER / START_INDEXING / REMOVE_DEVICE =
822867 // 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
824869 const existing = await ctx.tx.get(ENTRIES, { key: rec.key })
825870 if (existing && typeof existing.ts === 'number' && existing.ts > (rec.ts || 0)) return
826871 await ctx.tx.insert(ENTRIES, { key: rec.key, value: rec.value, ts: rec.ts || 0 })
……@@ -843,6 +888,18 @@ export class StateBaseV3 {
843888 await this.base.update()
844889 }
845890
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
846903 // Set the minimum attestation epoch (atproto-base-REVOCATION §3). receiptHex = a keet receipt
847904 // encoding the new minimum epoch (membership.encodeReceipt). Authorized by the caller's admit
848905 // proof (role 1). After this, attestations below the epoch are rejected on admission.
……@@ -883,6 +940,41 @@ export class StateBaseV3 {
883940 } catch { return false }
884941 }
885942
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
886978 // ── One-time in-place re-adopt migration (namespace rename) ─────────────────
887979 // The 'hyper-tps/state/v3' -> 'atproto-room/state/v3' rename changes the derived base key, so a
888980 // room created under the old namespace opens here as a FRESH, empty base that shares the same
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "atproto-room",
3 "version": "0.2.20",
3 "version": "0.2.21",
44 "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.",
55 "type": "module",
66 "main": "index.js",
test/all.js +1 -0
……@@ -13,6 +13,7 @@ import './signer.test.js'
1313import './signer-conformance.test.js'
1414import './membership.test.js'
1515import './doorbell-durable-write.test.js'
16import './doorbell-bounded.test.js'
1617import './admission-members-projection.test.mjs'
1718import './devices-projection.test.js'
1819import './phase2-nonblocking.test.js'
test/doorbell-bounded.test.js +164 -0
……@@ -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
24import '../lib/bare-globals.js'
25import test from 'brittle'
26import crypto from 'hypercore-crypto'
27import fs from 'bare-fs'
28import b4a from 'b4a'
29import createTestnet from '@hyperswarm/testnet'
30import { StateBaseV3 } from '../lib/state-base.js'
31import { makeAttest, makeVerifyAttest, attestDeviceChain, deviceKeetKeyPair, rootIdentityPublicKey } from '../lib/membership.js'
32
33const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
34const DEVA = 'legal winner thank year wave sausage worth useful legal winner thank yellow'
35const DEVB = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
36const tmp = () => { const d = '/tmp/hpp-doorbell-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }
37const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
38const hex = (k) => b4a.toString(k, 'hex')
39const 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.
42const 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`).
50async 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
65test('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
89test('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
121test('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
143test('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})