OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-room commits / 2ffc837

state-base: accept an injected shared Corestore + wakeup (multi-DID hpp foundation)
Step 1 of multi-DID hpp: let ONE daemon host N DIDs. StateBaseV3 optionally takes opts.store (a shared Corestore) + opts.wakeup (a shared ProtomuxWakeup); with network:false it then opens on a per-DID NAMESPACE of the one store and creates no swarm — the daemon owns store+swarm+wakeup and drives `wakeup.addStream(conn); store.replicate(conn)` per connection + swarm.join per DID (the keet multi-room pattern). Backward-compatible: absent opts.store it behaves exactly as before (new Corestore per room dir + own swarm). Grounded in research: the RocksDB/fd-lock is per Corestore DIRECTORY, not per namespace (keet-core runs N rooms this way; our atproto-bridge fronts N DIDs this way). atproto's own PDS is likewise multi-tenant (N per-account actor-stores in one process). One Corestore + N namespaces = one lock, N cryptographically-isolated core-sets. Proven: test/multi-did.test.js — 2 DIDs, each a creator on one shared store + a member on another, one Corestore/Hyperswarm/ProtomuxWakeup per machine; both members ADMITTED via the encrypted optimistic doorbell over the ONE shared wakeup, data isolated per DID, exactly one CORESTORE lock per machine (12/12). Legacy path regressions all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2ffc83798a845a9aba5907758d620e21d03421d6
by plankatron <fleeky@gmail.com> · 2026-07-17T15:55:54.000Z

Changed files (4) +158 -5

lib/state-base.js +28 -4
……@@ -99,8 +99,10 @@ export class StateBaseV3 {
9999 if (this._roomKey.length !== 32) throw new Error('roomKey must be 32 bytes (or 64-char hex)')
100100 this._roomKeyHex = this._roomKey.toString('hex')
101101
102 // Distinct v3 path — v2 'state-bases/' is left untouched (clean cutover).
103 this._storagePath = path.join(storagePath, 'state-bases-v3', this._roomKeyHex)
102 // Distinct v3 path — v2 'state-bases/' is left untouched (clean cutover). Null when a shared
103 // Corestore is injected (opts.store, multi-DID): storage is a namespace of the daemon's store,
104 // so there is no per-room dir.
105 this._storagePath = storagePath ? path.join(storagePath, 'state-bases-v3', this._roomKeyHex) : null
104106 this._network = opts.network !== false
105107 this._swarmOpts = opts.swarmOpts || null
106108 // Designated always-on anchor: on open, claim the SOLE indexer slot via
……@@ -157,6 +159,15 @@ export class StateBaseV3 {
157159 this._detBaseKey = this._gsKeyPair ? deriveBaseKey(this._gsKeyPair.publicKey) : null
158160 this._encryptionKey = opts.encryptionKey ? toBuf(opts.encryptionKey) : null
159161
162 // Multi-DID (multi-tenant) support. An injected SHARED Corestore + shared ProtomuxWakeup let ONE
163 // daemon host N DIDs: each StateBase opens on a per-DID NAMESPACE of the one store (one dir, one
164 // RocksDB lock, cryptographically-isolated cores) and shares one wakeup — exactly how keet-core
165 // fronts N rooms over one corestore/swarm. When opts.store is given, pass network:false too: this
166 // StateBase then creates NO Corestore and NO swarm; the daemon owns store+swarm+wakeup and drives
167 // `wakeup.addStream(conn); store.replicate(conn)` per connection + swarm.join(sb.base.discoveryKey).
168 this._injectedStore = opts.store || null
169 this._wakeup = opts.wakeup || null
170
160171 this._router = makeRouter()
161172 this.store = null
162173 this.base = null
……@@ -187,8 +198,16 @@ export class StateBaseV3 {
187198 async ready () {
188199 if (this._ready) return
189200 if (this._closing) throw new Error('StateBaseV3 is closing')
190 fs.mkdirSync(this._storagePath, { recursive: true })
191 this.store = new Corestore(this._storagePath)
201 if (this._injectedStore) {
202 // Shared-store (multi-DID) path: a per-DID NAMESPACE of the daemon's one Corestore — no new
203 // dir, no second RocksDB lock (the root's lock covers every namespace; a 2nd Corestore on the
204 // same dir fails to lock, a namespace does not). Proven end-to-end in test/multi-did.test.js
205 // (2 DIDs, one lock, encrypted admission over one shared wakeup, isolated).
206 this.store = this._injectedStore.namespace(this._roomKeyHex)
207 } else {
208 fs.mkdirSync(this._storagePath, { recursive: true })
209 this.store = new Corestore(this._storagePath)
210 }
192211 await this.store.ready()
193212 try {
194213 await this._open()
……@@ -325,6 +344,11 @@ export class StateBaseV3 {
325344 }
326345 if (keyPair) baseOpts.keyPair = keyPair
327346 if (this._encryptionKey) { baseOpts.encryptionKey = this._encryptionKey; baseOpts.encrypted = true }
347 // Shared wakeup (multi-DID): all DIDs on the daemon share ONE ProtomuxWakeup, so a single
348 // `wakeup.addStream(conn)` carries every DID's optimistic/pending-writer announcements — the
349 // admission doorbell works over the shared connection with no per-base wakeup (autobase sets
350 // wakeupOwner=false when handlers.wakeup is injected). Pair with network:false (daemon-driven repl).
351 if (this._wakeup) baseOpts.wakeup = this._wakeup
328352
329353 this.base = new Autobase(this.store, bootstrapKey, baseOpts)
330354 await this.base.ready()
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "atproto-room",
3 "version": "0.2.2",
3 "version": "0.2.3",
44 "description": "Attestation-gated, encrypted multi-writer room engine \u2014 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
……@@ -11,6 +11,7 @@ import './signer-conformance.test.js'
1111import './membership.test.js'
1212import './doorbell-durable-write.test.js'
1313import './phase2-nonblocking.test.js'
14import './multi-did.test.js'
1415import './non-indexer-default.test.js'
1516import './co-authored-labeler.test.js'
1617import './migration-readopt.test.js'
test/multi-did.test.js +128 -0
……@@ -0,0 +1,128 @@
1// Multi-DID (multi-tenant) StateBaseV3 — the multi-DID-hpp foundation.
2//
3// Proves ONE daemon can host N DIDs over ONE Corestore + ONE Hyperswarm + ONE ProtomuxWakeup: each
4// DID is a StateBaseV3 opened on a per-DID NAMESPACE of the shared store (opts.store) with the shared
5// wakeup (opts.wakeup) and network:false (the daemon drives replication). We run 2 DIDs, each with a
6// creator on "machine 1" and a member on "machine 2", and assert the FULL encrypted, attestation-gated
7// admission doorbell works PER DID over the shared connection, data is isolated per DID, and each
8// machine holds exactly ONE storage lock. This is the harder half the foundational corestore test
9// (test/multi-did-mechanism) didn't cover: encrypted optimistic admission over a SHARED wakeup.
10import '../lib/bare-globals.js'
11import test from 'brittle'
12import crypto from 'hypercore-crypto'
13import fs from 'bare-fs'
14import b4a from 'b4a'
15import Corestore from 'corestore'
16import Hyperswarm from 'hyperswarm'
17import ProtomuxWakeup from 'protomux-wakeup'
18import createTestnet from '@hyperswarm/testnet'
19import { StateBaseV3 } from '../lib/state-base.js'
20import { makeAttest, makeVerifyAttest, attestDeviceChain, deviceKeetKeyPair, rootIdentityPublicKey } from '../lib/membership.js'
21
22const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
23// 4 device identities: 2 per DID (a creator + a member).
24const MNEMS = [
25 'legal winner thank year wave sausage worth useful legal winner thank yellow',
26 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong',
27 'letter advice cage absurd amount doctor acoustic avoid letter advice cage above',
28 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote'
29]
30const tmp = () => { const d = '/tmp/hpp-mtdid-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }
31const sleep = (ms) => new Promise(r => setTimeout(r, ms))
32
33// Build a DID spec: distinct room secrets + a creator (lowest key) + a member, both attested to ROOT.
34async function mkDid (rootPub, m0, m1) {
35 let cd = await deviceKeetKeyPair(m0); let cp = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: cd.publicKey })
36 let md = await deviceKeetKeyPair(m1); let mp = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: md.publicKey })
37 let creator = { dev: cd, proof: cp }; let member = { dev: md, proof: mp }
38 if (b4a.compare(creator.dev.publicKey, member.dev.publicKey) > 0) { const t = creator; creator = member; member = t } // creator = lowest key
39 const members = [
40 { key: b4a.toString(creator.dev.publicKey, 'hex'), role: 1 },
41 { key: b4a.toString(member.dev.publicKey, 'hex'), role: 1 }
42 ]
43 return { rootPub, creator, member, members, roomSeed: crypto.randomBytes(32), roomKey: crypto.randomBytes(32), encryptionKey: crypto.randomBytes(32) }
44}
45
46// A StateBaseV3 on a SHARED store + SHARED wakeup, network:false (daemon-driven replication).
47function mkSb (store, wakeup, did, who) {
48 const dev = did[who]
49 return new StateBaseV3(null, did.roomKey, {
50 store, wakeup, network: false,
51 roomSeed: did.roomSeed, encryptionKey: did.encryptionKey,
52 selfKey: dev.dev.publicKey, attest: makeAttest(dev.dev, dev.proof), verifyAttest: makeVerifyAttest(did.rootPub),
53 getMembers: async () => did.members
54 })
55}
56
57// Wire the daemon side: one connection handler that muxes EVERY DID's cores + wakeup, then join each
58// DID's base topic. This is exactly what a multi-DID hpp daemon does.
59function wireDaemon (swarm, store, wakeup, sbs) {
60 swarm.on('connection', (conn) => {
61 conn.on('error', () => {})
62 try { wakeup.addStream(conn) } catch {}
63 try { store.replicate(conn) } catch {}
64 })
65 for (const sb of sbs) swarm.join(sb.base.discoveryKey, { server: true, client: true })
66}
67
68test('2 DIDs on ONE shared store/swarm/wakeup per machine: admission + isolation + one lock', { timeout: 120000 }, async (t) => {
69 const testnet = await createTestnet(3)
70 const rootPub = await rootIdentityPublicKey(ROOT)
71 const A = await mkDid(rootPub, MNEMS[0], MNEMS[1])
72 const B = await mkDid(rootPub, MNEMS[2], MNEMS[3])
73
74 // ── Machine 1 (creators): ONE store, ONE swarm, ONE wakeup ──
75 const dir1 = tmp()
76 const store1 = new Corestore(dir1); await store1.ready()
77 const swarm1 = new Hyperswarm({ bootstrap: testnet.bootstrap })
78 const wakeup1 = new ProtomuxWakeup()
79 const cA = mkSb(store1, wakeup1, A, 'creator'); await cA.ready()
80 const cB = mkSb(store1, wakeup1, B, 'creator'); await cB.ready()
81 await cA.projectMembers(A.members); await cB.projectMembers(B.members)
82 wireDaemon(swarm1, store1, wakeup1, [cA, cB])
83 await swarm1.flush().catch(() => {})
84
85 // ── Machine 2 (members): ONE store, ONE swarm, ONE wakeup ──
86 const dir2 = tmp()
87 const store2 = new Corestore(dir2); await store2.ready()
88 const swarm2 = new Hyperswarm({ bootstrap: testnet.bootstrap })
89 const wakeup2 = new ProtomuxWakeup()
90 const mA = mkSb(store2, wakeup2, A, 'member'); await mA.ready()
91 const mB = mkSb(store2, wakeup2, B, 'member'); await mB.ready()
92 wireDaemon(swarm2, store2, wakeup2, [mA, mB])
93 await swarm2.flush().catch(() => {})
94
95 t.teardown(async () => {
96 for (const sb of [cA, cB, mA, mB]) { try { await sb.close() } catch {} }
97 try { await store1.close() } catch {}; try { await store2.close() } catch {}
98 try { await swarm1.destroy() } catch {}; try { await swarm2.destroy() } catch {}
99 await testnet.destroy()
100 })
101
102 t.ok(cA.base.writable && cB.base.writable, 'both creators writable on ONE shared store (machine 1)')
103 t.absent(mA.base.writable, 'member A starts non-writable (awaiting admission)')
104 t.absent(mB.base.writable, 'member B starts non-writable (awaiting admission)')
105
106 // ── ADMISSION: each member writes → its own DID's creator admits it (encrypted optimistic doorbell
107 // over the SHARED wakeup). Distinct content per DID. ──
108 await mA.set('post/1', { did: 'A', text: 'hello-from-A' })
109 await mB.set('post/1', { did: 'B', text: 'hello-from-B' })
110 t.ok(mA.base.writable, 'member A got ADMITTED over the shared connection (DID A)')
111 t.ok(mB.base.writable, 'member B got ADMITTED over the shared connection (DID B)')
112
113 // converge
114 for (let i = 0; i < 16; i++) { for (const sb of [cA, cB, mA, mB]) await sb.base.update().catch(() => {}); await sleep(300) }
115
116 // ── ISOLATION: each DID's data is its own; no cross-tenant bleed ──
117 t.alike(await mA.get('post/1'), { did: 'A', text: 'hello-from-A' }, 'member A reads its own durable write back')
118 t.alike(await cA.get('post/1'), { did: 'A', text: 'hello-from-A' }, 'creator A converged with A\'s write')
119 t.alike(await cB.get('post/1'), { did: 'B', text: 'hello-from-B' }, 'creator B converged with B\'s write')
120 t.absent(await cA.get('post/1') && (await cA.get('post/1')).did === 'B', 'DID A base has NO DID B data (isolation)')
121 t.is((await cB.get('post/1')).did, 'B', 'DID B base holds only B\'s record (isolation)')
122
123 // ── ONE lock per machine (per-directory) ──
124 const locks1 = fs.readdirSync(dir1).filter(f => /CORESTORE/i.test(f))
125 const locks2 = fs.readdirSync(dir2).filter(f => /CORESTORE/i.test(f))
126 t.is(locks1.length, 1, 'machine 1: exactly ONE CORESTORE lock for both DIDs')
127 t.is(locks2.length, 1, 'machine 2: exactly ONE CORESTORE lock for both DIDs')
128})