OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-orgs commits / 395bf4f

Add mirror mode — mountOrg + listOrgKeys (join-all path)
Two coexisting modes on one per-org-autobase mechanism: - peer mode: joinOrg(invite) → mount one specific org - mirror mode: listOrgKeys() (primary exposes all org keys) + mountOrg(name, key) per entry → full replica joinOrg is now a thin wrapper over mountOrg (invite→key). The post-mortem leak is avoided because following the key-list is opt-in (mirror), while peers only ever receive specific invites. mirror-mode test: mirror mounts all 3 orgs from the key-list and sees every org + data. Suite 24/24, 68 asserts. (HA writer-enrollment for mirrors reuses enrollWriter — follow-up.) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
395bf4fe9b3fa204c127ca5c8e921d05acffbcac
by plankatron <fleeky@gmail.com> · 2026-05-25T00:25:01.000Z

Changed files (2) +87 -15

lib/hyper-orgs.js +29 -15
……@@ -250,28 +250,42 @@ export class HyperOrgs {
250250 const decoded = decodeInvite(invitation)
251251 if (decoded.kind !== 'org') throw new Error(`invite is for "${decoded.kind}", not an org`)
252252 if (isExpired(decoded)) throw new Error('invite expired')
253 if (!decoded.name) throw new Error('org invite missing name (cannot key the org)')
254 return this.mountOrg(decoded.name, decoded.bootstrapKey, decoded.roles[0] || 'reader')
255 }
253256
254 const name = decoded.name
255 if (!name) throw new Error('org invite missing name (cannot key the org)')
256 const role = decoded.roles[0] || 'reader'
257 /**
258 * Mount an org's autobase by its bootstrap key directly (no invite
259 * ceremony). PEER mode uses joinOrg (invite → key). MIRROR mode uses
260 * this: the primary's listOrgKeys() yields {name, bootstrapKey} for
261 * every org, and a mirror mounts each → full replica. Idempotent.
262 * @returns {Promise<{ name, writerKey, role }>}
263 */
264 async mountOrg (name, bootstrapKey, role = 'reader') {
257265 if (this._orgs.has(name)) {
258 return { name, writerKey: this._orgs.get(name).room.localKeyHex, role }
266 return { name, writerKey: this._orgs.get(name).room.localKeyHex, role: this._orgs.get(name).role }
259267 }
260
261 const room = new Room(this._corestore, {
262 schema: orgsSchema,
263 // Namespace by bootstrap key so a joiner's local writer is distinct
264 // from the creator's; the autobase identity is the bootstrap key.
265 namespace: 'org-join/' + decoded.bootstrapKey.slice(0, 16),
266 bootstrapKey: decoded.bootstrapKey
267 })
268 await room.ready()
268 const room = await this._openOrgRoom(name, bootstrapKey, { created: false, role })
269269 await room.base.update()
270 this._orgs.set(name, { room, role, created: false })
271 await this._track(name, decoded.bootstrapKey, role, false)
270 await this._track(name, bootstrapKey, role, false)
272271 return { name, writerKey: room.localKeyHex, role }
273272 }
274273
274 /**
275 * Every org this instance tracks, with its bootstrap key. The primary
276 * exposes this so a mirror can mountOrg() each for a full replica
277 * (mirror mode). Peers never call this — they only get specific
278 * invites (peer mode), which is what prevents the post-mortem leak.
279 * @returns {Array<{ name, bootstrapKey, role, created }>}
280 */
281 listOrgKeys () {
282 const out = []
283 for (const [name, e] of this._orgs) {
284 out.push({ name, bootstrapKey: e.room.keyHex, role: e.role, created: e.created })
285 }
286 return out
287 }
288
275289 // ── Resources (per-org) ──────────────────────────────────────
276290
277291 async addResource (orgName, resourceName) {
test/mirror-mode.test.js +58 -0
……@@ -0,0 +1,58 @@
1// Mirror mode (issue #35): a sister daemon replicates the WHOLE forge by
2// following the primary's org-key list and mounting EVERY org. Contrast
3// with peer mode (join specific orgs via invite). Both mount per-org
4// autobases — mirror just grabs them all.
5//
6// (This proves the read-replica path: mirror sees all orgs. HA write
7// enrollment for mirrors — making the sister a writer too — reuses
8// enrollWriter and is a follow-up.)
9
10import test from 'brittle'
11import Corestore from 'corestore'
12import { mkdtempSync } from 'fs'
13import { join } from 'path'
14import { tmpdir } from 'os'
15import { replicateAndSync } from 'autobase-test-helpers'
16import { HyperOrgs } from '../index.js'
17
18function tmpStore () {
19 return new Corestore(mkdtempSync(join(tmpdir(), 'hyper-orgs-mirror-')))
20}
21
22test('#35 mirror mode: mount ALL orgs from the primary key-list', async (t) => {
23 const storeP = tmpStore()
24 const storeM = tmpStore()
25
26 const primary = new HyperOrgs(storeP, { namespace: 'mir-primary' })
27 await primary.ready()
28 await primary.create('alpha', 'group', { displayName: 'Alpha' })
29 await primary.create('beta', 'group')
30 await primary.create('gamma', 'group')
31 await primary.addResource('alpha', 'repo-1')
32 await primary.sync()
33
34 // Primary exposes its org keys (over RPC in the daemon; here directly).
35 const keys = primary.listOrgKeys()
36 t.is(keys.length, 3, 'primary lists all 3 org keys')
37
38 // Mirror mounts EVERY org by key (no invite — mirror mode).
39 const mirror = new HyperOrgs(storeM, { namespace: 'mir-mirror' })
40 await mirror.ready()
41 for (const k of keys) await mirror.mountOrg(k.name, k.bootstrapKey, 'reader')
42
43 for (const k of keys) {
44 await replicateAndSync([primary.getOrgBase(k.name), mirror.getOrgBase(k.name)])
45 }
46
47 // Full replica: mirror sees every org + its data.
48 for (const n of ['alpha', 'beta', 'gamma']) t.ok(await mirror.get(n), `mirror has ${n}`)
49 t.is((await mirror.list()).length, 3, 'mirror replicates all orgs')
50 t.alike((await mirror.get('alpha')).resources, ['repo-1'], 'mirror has alpha data')
51
52 t.teardown(async () => {
53 try { await primary.close() } catch {}
54 try { await mirror.close() } catch {}
55 try { await storeP.close() } catch {}
56 try { await storeM.close() } catch {}
57 })
58})