OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-orgs commits / 7410cfe

Swap org join/enroll onto blind-pairing (P2P invite, no HTTP)
createOrgInvite(name, { role, swarm }) now mints a hyper-rooms RoomPairing invite (org name in meta) and holds the live pairing member; joinOrg(invite, { swarm }) pairs over the swarm and, for contributor+, is addWritered DURING pairing. The owner-enroll-over-HTTP path (enrollWriter / enrollFromInvite + _roomRole) is deleted — the write-grant now rides the same P2P channel as discovery. mountOrg (mirror mode) is unchanged; close() tears down pairings. Tests: retired the in-memory bearer+enroll tests (scope-isolation / multi-org-join / writer-vs-reader / enroll-from-invite — they exercised the removed API) and replaced them with test/org-pairing.test.js over a real testnet: contributor -> writer -> bidirectional, reader -> read-only mirror, scope isolation (joined alpha never sees beta), multi-org. Full suite green: basic 19/19, mirror-mode 1/1, org-issues-linked-core (#42) 2/2, org-pairing 3/3, persistence 1/1. hyperswarm + @hyperswarm/testnet added as devDependencies (test-only — the feature takes the swarm injected; blind-pairing is transitive via hyper-rooms). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
7410cfe7cab6c1c0550ebd27dacf6f26c5e50f7a
by plankatron <fleeky@gmail.com> · 2026-05-27T19:41:36.000Z

Changed files (7) +173 -342

lib/hyper-orgs.js +55 -85
……@@ -24,7 +24,7 @@
2424 * today joiners are read-only followers of the org's autobase.
2525 */
2626
27import { Room, composeSchemas, encodeInvite, decodeInvite, isExpired } from 'hyper-rooms'
27import { Room, composeSchemas, createRoomPairing, joinRoomPairing, decodeRoomPairingInvite } from 'hyper-rooms'
2828import { orgsSchema, validateName } from './schema.js'
2929
3030export class HyperOrgs {
……@@ -45,6 +45,9 @@ export class HyperOrgs {
4545 : orgsSchema
4646 // name -> { room: Room, role: string, created: boolean }
4747 this._orgs = new Map()
48 // invite string -> pairing handle (live blind-pairing member). Held so
49 // outstanding invites keep accepting joiners and are closed with us.
50 this._pairings = new Map()
4851 // Local append-only index (NOT replicated): which orgs this instance
4952 // tracks + how to re-open each Room on restart. {op,name,bootstrapKey,
5053 // role,created}. Last-write-wins per name; op:'untrack' drops.
……@@ -111,6 +114,10 @@ export class HyperOrgs {
111114 }
112115
113116 async close () {
117 for (const pairing of this._pairings.values()) {
118 try { await pairing.close() } catch {}
119 }
120 this._pairings.clear()
114121 for (const { room } of this._orgs.values()) {
115122 try { await room.close() } catch {}
116123 }
……@@ -238,47 +245,59 @@ export class HyperOrgs {
238245 // ── Invites / join (the per-org-autobase scope mechanism) ────
239246
240247 /**
241 * Issue a bearer invite for one org. Holding it lets a peer mount
242 * ONLY this org's autobase.
248 * Issue a blind-pairing invite for one org (the canonical Keet flow). The
249 * returned `invitation` is the whole UX: a joiner pairs over the swarm and,
250 * for contributor+, is addWritered DURING pairing — no HTTP, no separate
251 * enroll. The pairing member stays live (held in this._pairings) until
252 * closed; the org's name travels in the invite `meta` so the joiner can key
253 * its record. Requires the daemon's shared swarm (P2P transport).
243254 * @param {string} name
244 * @param {object} [opts]
255 * @param {object} opts
245256 * @param {string} [opts.role='reader']
246 * @param {number} [opts.expiresAt]
247 * @param {number} [opts.maxUses]
248 * @returns {{ invitation: string }}
257 * @param {object} opts.swarm shared Hyperswarm (injected)
258 * @returns {Promise<{ invitation: string, close: () => Promise<void> }>}
249259 */
250260 async createOrgInvite (name, opts = {}) {
251261 const room = this._requireRoom(name)
252 const invitation = encodeInvite({
253 bootstrapKey: room.keyHex,
254 kind: 'org',
255 name,
256 roles: [opts.role || 'reader'],
257 source: opts.source || null,
258 expiresAt: opts.expiresAt || null,
259 maxUses: opts.maxUses == null ? 1 : opts.maxUses
262 if (!opts.swarm) throw new Error('createOrgInvite requires { swarm } (P2P pairing transport)')
263 const pairing = createRoomPairing(room, {
264 role: opts.role || 'reader',
265 swarm: opts.swarm,
266 meta: { kind: 'org', name }
260267 })
261 return { invitation }
268 this._pairings.set(pairing.invite, pairing)
269 return { invitation: pairing.invite, close: pairing.close }
262270 }
263271
264272 /**
265 * Consume an org invite: mount ONLY that org's autobase. The joiner
266 * starts as a read-only follower; to gain write access it hands its
267 * returned `writerKey` to an existing writer (the owner) who calls
268 * enrollWriter() — the "owner auto-approves on connect" model (B).
269 * In the daemon that hand-off is the device-invite/pairing channel;
270 * in-process callers pass writerKey across directly.
273 * Consume an org invite via blind-pairing: mount ONLY that org's autobase
274 * and, for contributor+, become a writer DURING pairing (the owner
275 * addWriters us over the swarm). For 'reader' we stay a read-only mirror.
276 * The write-grant rides the same P2P channel as discovery — no HTTP.
271277 * @param {string} invitation
272 * @returns {Promise<{ name: string, writerKey: string, role: string }>}
278 * @param {object} opts
279 * @param {object} opts.swarm shared Hyperswarm (injected)
280 * @returns {Promise<{ name: string, role: string }>}
273281 */
274 async joinOrg (invitation) {
275 const decoded = decodeInvite(invitation)
276 if (decoded.kind !== 'org') throw new Error(`invite is for "${decoded.kind}", not an org`)
277 if (isExpired(decoded)) throw new Error('invite expired')
278 if (!decoded.name) throw new Error('org invite missing name (cannot key the org)')
279 const res = await this.mountOrg(decoded.name, decoded.bootstrapKey, decoded.roles[0] || 'reader')
280 // `source` (owner serverKey) lets the caller request write-enrollment.
281 return { ...res, source: decoded.source || null }
282 async joinOrg (invitation, opts = {}) {
283 if (!opts.swarm) throw new Error('joinOrg requires { swarm } (P2P pairing transport)')
284 const dec = decodeRoomPairingInvite(invitation)
285 if (!dec.meta || dec.meta.kind !== 'org') throw new Error('invite is not an org pairing invite')
286 const name = dec.meta.name
287 if (!name) throw new Error('org pairing invite missing name (cannot key the org)')
288 if (this._orgs.has(name)) {
289 const e = this._orgs.get(name)
290 return { name, role: e.role }
291 }
292 const { room, role } = await joinRoomPairing(invitation, {
293 corestore: this._corestore,
294 swarm: opts.swarm,
295 schema: this._schema,
296 namespace: 'org-join/' + dec.bootstrapKey.slice(0, 16)
297 })
298 this._orgs.set(name, { room, role, created: false })
299 await this._track(name, room.keyHex, role, false)
300 return { name, role }
282301 }
283302
284303 /**
……@@ -511,60 +530,11 @@ export class HyperOrgs {
511530 return false
512531 }
513532
514 // ── Enrollment (owner-approves model, B) ─────────────────────
515
516 // Map an invite role to a Room writer role:
517 // reader → 'mirror' (replicates, NOT an autobase writer)
518 // contributor/maintainer/admin → 'writer' (can append)
519 // Only the org creator is the indexer ('owner'); finer per-op role
520 // enforcement (e.g. admin-only ops) is a later increment.
521 _roomRole (inviteRole) {
522 return inviteRole === 'reader' ? 'mirror' : 'writer'
523 }
524
525 /**
526 * Owner side of B: enroll a joiner's writer key with the role its
527 * invite granted. For write-capable roles this makes the joiner an
528 * autobase writer; for 'reader' it records membership but leaves them
529 * read-only. Caller must be an existing writer of the org.
530 * @param {string} orgName
531 * @param {string} writerKey the joiner's localKeyHex (from joinOrg)
532 * @param {string} [inviteRole='reader']
533 * @returns {Promise<string>} the member userId recorded
534 */
535 async enrollWriter (orgName, writerKey, inviteRole = 'reader') {
536 const room = this._requireRoom(orgName)
537 const userId = 'member-' + writerKey.slice(0, 16)
538 await room.addWriter(userId, {
539 writerKey,
540 role: this._roomRole(inviteRole),
541 addedBy: 'owner-enroll'
542 })
543 await room.base.update()
544 return userId
545 }
546
547 /**
548 * Owner side, invite-authorized: a joiner presents the invite it holds
549 * + its writer key; we verify the invite refers to an org WE own
550 * (bootstrapKey + name match a tracked, created org), then enroll with
551 * the invite's role. Possessing a valid invite is the authorization —
552 * no prior membership required. This is what the daemon's enroll-writer
553 * RPC calls (the joiner's daemon hands these over via peer RPC).
554 * @returns {Promise<{ orgName, role, userId }>}
555 */
556 async enrollFromInvite (invitation, writerKey) {
557 const decoded = decodeInvite(invitation)
558 if (decoded.kind !== 'org') throw new Error(`invite is for "${decoded.kind}", not an org`)
559 if (isExpired(decoded)) throw new Error('invite expired')
560 const e = this._orgs.get(decoded.name)
561 if (!e || !e.created || e.room.keyHex !== decoded.bootstrapKey) {
562 throw new Error(`invite does not match an org owned by this instance`)
563 }
564 const role = decoded.roles[0] || 'reader'
565 const userId = await this.enrollWriter(decoded.name, writerKey, role)
566 return { orgName: decoded.name, role, userId }
567 }
533 // Write-enrollment now happens DURING blind-pairing (createOrgInvite /
534 // joinOrg, via hyper-rooms RoomPairing) — the owner addWriters the joiner
535 // over the swarm. The old owner-enroll-over-HTTP path (enrollWriter /
536 // enrollFromInvite) is retired; addWriter() below remains for mirror-mode
537 // HA writer coordination.
568538
569539 // ── Writer management (per-org, delegated to Room) ───────────
570540
package.json +3 -1
……@@ -11,9 +11,11 @@
1111 "hyper-rooms": "file:../hyper-rooms"
1212 },
1313 "devDependencies": {
14 "@hyperswarm/testnet": "^3.1.4",
1415 "autobase-test-helpers": "^2.0.0",
1516 "brittle": "^3.0.0",
1617 "corestore": "^7.0.0",
17 "hyper-issues": "file:../hyper-issues"
18 "hyper-issues": "file:../hyper-issues",
19 "hyperswarm": "^4.8.0"
1820 }
1921}
test/enroll-from-invite.test.js +0 -47
……@@ -1,47 +0,0 @@
1// enrollFromInvite (issue #35): the daemon's enroll-writer path. A joiner
2// presents the invite it holds + its writer key; the owner verifies the
3// invite refers to an org IT owns, then enrolls with the invite's role.
4// Possessing the invite is the authorization (no prior membership).
5
6import test from 'brittle'
7import Corestore from 'corestore'
8import { mkdtempSync } from 'fs'
9import { join } from 'path'
10import { tmpdir } from 'os'
11import { replicateAndSync } from 'autobase-test-helpers'
12import { HyperOrgs } from '../index.js'
13
14function tmpStore () {
15 return new Corestore(mkdtempSync(join(tmpdir(), 'hyper-orgs-efi-')))
16}
17
18test('#35: enrollFromInvite — invite-authorized owner enroll', async (t) => {
19 const owner = new HyperOrgs(tmpStore(), { namespace: 'efi-owner' })
20 const joiner = new HyperOrgs(tmpStore(), { namespace: 'efi-joiner' })
21 await owner.ready()
22 await joiner.ready()
23 t.teardown(async () => { try { await owner.close() } catch {}; try { await joiner.close() } catch {} })
24
25 await owner.create('proj', 'group')
26 const { invitation } = await owner.createOrgInvite('proj', { role: 'contributor' })
27
28 // joiner mounts + hands {invitation, writerKey} to the owner
29 const j = await joiner.joinOrg(invitation)
30 await replicateAndSync([owner.getOrgBase('proj'), joiner.getOrgBase('proj')])
31
32 const res = await owner.enrollFromInvite(invitation, j.writerKey)
33 t.is(res.orgName, 'proj')
34 t.is(res.role, 'contributor')
35
36 await replicateAndSync([owner.getOrgBase('proj'), joiner.getOrgBase('proj')])
37 await replicateAndSync([owner.getOrgBase('proj'), joiner.getOrgBase('proj')])
38 t.ok(joiner.getOrgRoom('proj').writable, 'joiner became a writer via invite enroll')
39
40 // a forged invite for an org the owner doesn't own is rejected
41 const other = new HyperOrgs(tmpStore(), { namespace: 'efi-other' })
42 await other.ready()
43 await other.create('elsewhere', 'group')
44 const { invitation: foreign } = await other.createOrgInvite('elsewhere', { role: 'admin' })
45 await t.exception(() => owner.enrollFromInvite(foreign, j.writerKey), /does not match an org owned/)
46 await other.close()
47})
test/multi-org-join.test.js +0 -66
……@@ -1,66 +0,0 @@
1// Layer-2 failing spec (issue #35): a peer can join N orgs via N invites
2// and sees ONLY those orgs — not the others on the inviter. Exercises the
3// per-org-autobase model's multi-room management (one autobase per joined
4// org, keyed by name).
5//
6// Fails today: createOrgInvite/joinOrg/getOrgBase don't exist yet.
7
8import test from 'brittle'
9import Corestore from 'corestore'
10import { mkdtempSync } from 'fs'
11import { join } from 'path'
12import { tmpdir } from 'os'
13import { replicateAndSync } from 'autobase-test-helpers'
14import { HyperOrgs } from '../index.js'
15
16function tmpStore () {
17 return new Corestore(mkdtempSync(join(tmpdir(), 'hyper-orgs-multi-')))
18}
19
20test('SPEC #35: join alpha + gamma → both visible, beta absent', async (t) => {
21 const storeA = tmpStore()
22 const storeB = tmpStore()
23
24 const owner = new HyperOrgs(storeA, { namespace: 'multi-owner' })
25 await owner.ready()
26 await owner.create('alpha', 'group', { displayName: 'Alpha' })
27 await owner.create('beta', 'group', { displayName: 'Beta' })
28 await owner.create('gamma', 'group', { displayName: 'Gamma' })
29 await owner.sync()
30
31 if (typeof owner.createOrgInvite !== 'function') {
32 t.fail('REGRESSION: HyperOrgs.createOrgInvite not implemented (per-org-autobase refactor pending)')
33 return
34 }
35
36 const { invitation: alphaInvite } = await owner.createOrgInvite('alpha', { role: 'reader' })
37 const { invitation: gammaInvite } = await owner.createOrgInvite('gamma', { role: 'reader' })
38
39 const joiner = new HyperOrgs(storeB, { namespace: 'multi-joiner' })
40 await joiner.ready()
41 if (typeof joiner.joinOrg !== 'function') {
42 t.fail('REGRESSION: HyperOrgs.joinOrg not implemented')
43 return
44 }
45 await joiner.joinOrg(alphaInvite)
46 await joiner.joinOrg(gammaInvite)
47
48 // Sync each joined org's autobase against the owner's matching one.
49 await replicateAndSync([owner.getOrgBase('alpha'), joiner.getOrgBase('alpha')])
50 await replicateAndSync([owner.getOrgBase('gamma'), joiner.getOrgBase('gamma')])
51
52 t.ok(await joiner.get('alpha'), 'joiner sees alpha')
53 t.ok(await joiner.get('gamma'), 'joiner sees gamma')
54 t.is(await joiner.get('beta'), null, 'SCOPE LEAK: joiner sees beta (never invited)')
55
56 // The joiner's local org set is exactly the two it joined.
57 const mine = (await joiner.list()).map(o => o.name).sort()
58 t.alike(mine, ['alpha', 'gamma'], 'joiner.list() == joined orgs only')
59
60 t.teardown(async () => {
61 try { await owner.close() } catch {}
62 try { await joiner.close() } catch {}
63 try { await storeA.close() } catch {}
64 try { await storeB.close() } catch {}
65 })
66})
test/org-pairing.test.js +115 -0
……@@ -0,0 +1,115 @@
1// Org join + write-enrollment via blind-pairing over Hyperswarm (the P2P
2// replacement for the bearer-invite + owner-enroll flow). Supersedes the
3// old in-memory tests (scope-isolation / multi-org-join / writer-vs-reader /
4// enroll-from-invite) which exercised the now-removed bearer+enroll API.
5//
6// Proves, over a real testnet:
7// - contributor: pairs → becomes a writer → its writes replicate to owner
8// - reader: pairs → read-only mirror (sees owner data, NOT a writer)
9// - scope isolation: a joiner that joined ONLY alpha never sees beta
10// - multi-org: a joiner can join two orgs and write to both
11
12import test from 'brittle'
13import Corestore from 'corestore'
14import Hyperswarm from 'hyperswarm'
15import createTestnet from '@hyperswarm/testnet'
16import { mkdtempSync } from 'fs'
17import { join } from 'path'
18import { tmpdir } from 'os'
19
20import { HyperOrgs } from '../index.js'
21
22let n = 0
23const tmp = () => mkdtempSync(join(tmpdir(), 'op-' + (n++) + '-'))
24
25async function waitFor (pred, ms = 25000) {
26 const t0 = Date.now()
27 while (Date.now() - t0 < ms) {
28 try { if (await pred()) return true } catch {}
29 await new Promise((r) => setTimeout(r, 100))
30 }
31 return false
32}
33
34async function makeOrgs (t, bootstrap) {
35 const store = new Corestore(tmp())
36 const swarm = new Hyperswarm({ bootstrap })
37 swarm.on('connection', (conn) => store.replicate(conn))
38 const orgs = new HyperOrgs(store)
39 await orgs.ready()
40 t.teardown(async () => { try { await orgs.close() } catch {}; try { await swarm.destroy() } catch {} })
41 return { orgs, swarm }
42}
43
44test('contributor pairs into an org and becomes a writer (P2P, no HTTP)', async (t) => {
45 const testnet = await createTestnet(3, t.teardown)
46 const A = await makeOrgs(t, testnet.bootstrap)
47 const B = await makeOrgs(t, testnet.bootstrap)
48
49 await A.orgs.create('alpha', 'group', { displayName: 'Alpha' })
50 await A.orgs.create('beta', 'group', { displayName: 'Beta' })
51
52 const { invitation } = await A.orgs.createOrgInvite('alpha', { role: 'contributor', swarm: A.swarm })
53 const joined = await B.orgs.joinOrg(invitation, { swarm: B.swarm })
54 t.is(joined.name, 'alpha', 'joined the named org')
55
56 const became = await waitFor(() => { const r = B.orgs.getOrgRoom('alpha'); return r && r.writable })
57 t.ok(became, 'contributor became a writer via pairing (over the swarm)')
58
59 // Joiner writes; owner sees it.
60 await B.orgs.addResource('alpha', 'repo:from-joiner')
61 const seen = await waitFor(async () => {
62 const org = await A.orgs.get('alpha')
63 return !!(org && Array.isArray(org.resources) && org.resources.includes('repo:from-joiner'))
64 })
65 t.ok(seen, 'owner sees the joiner-authored write (bidirectional)')
66
67 // Scope isolation: B joined ONLY alpha — beta never replicates to it.
68 t.absent(B.orgs.getOrgRoom('beta'), 'joiner has NOT mounted beta (scope-isolated)')
69 const beta = await B.orgs.get('beta').catch(() => null)
70 t.ok(!beta, 'joiner cannot see beta (only the invited org replicates)')
71})
72
73test('reader pairs into an org and stays a read-only mirror', async (t) => {
74 const testnet = await createTestnet(3, t.teardown)
75 const A = await makeOrgs(t, testnet.bootstrap)
76 const B = await makeOrgs(t, testnet.bootstrap)
77
78 await A.orgs.create('proj', 'group', { displayName: 'Proj' })
79 await A.orgs.addResource('proj', 'repo:owner-added')
80
81 const { invitation } = await A.orgs.createOrgInvite('proj', { role: 'reader', swarm: A.swarm })
82 const joined = await B.orgs.joinOrg(invitation, { swarm: B.swarm })
83 t.is(joined.role, 'reader', 'role echoed as reader')
84
85 // Reader replicates (reads) the org...
86 const sawRead = await waitFor(async () => {
87 const org = await B.orgs.get('proj')
88 return !!(org && Array.isArray(org.resources) && org.resources.includes('repo:owner-added'))
89 })
90 t.ok(sawRead, 'reader replicates the org read-only')
91
92 // ...but is NOT a writer.
93 await new Promise((r) => setTimeout(r, 1500))
94 t.absent(B.orgs.getOrgRoom('proj').writable, 'reader is NOT enrolled as a writer')
95})
96
97test('a joiner can pair into TWO orgs and write to both', async (t) => {
98 const testnet = await createTestnet(3, t.teardown)
99 const A = await makeOrgs(t, testnet.bootstrap)
100 const B = await makeOrgs(t, testnet.bootstrap)
101
102 await A.orgs.create('one', 'group', { displayName: 'One' })
103 await A.orgs.create('two', 'group', { displayName: 'Two' })
104
105 const i1 = (await A.orgs.createOrgInvite('one', { role: 'contributor', swarm: A.swarm })).invitation
106 const i2 = (await A.orgs.createOrgInvite('two', { role: 'contributor', swarm: A.swarm })).invitation
107 await B.orgs.joinOrg(i1, { swarm: B.swarm })
108 await B.orgs.joinOrg(i2, { swarm: B.swarm })
109
110 const both = await waitFor(() => {
111 const r1 = B.orgs.getOrgRoom('one'); const r2 = B.orgs.getOrgRoom('two')
112 return r1 && r1.writable && r2 && r2.writable
113 })
114 t.ok(both, 'joiner is a writer on both orgs')
115})
test/scope-isolation.test.js +0 -64
……@@ -1,64 +0,0 @@
1// Scope-isolation spec for the post-mortem (issue #35).
2//
3// The post-mortem (pear-git-v3 issue #35) captured a production failure:
4// a peer configured for "scope=alpha only" actually replicated ALL of
5// the source's orgs, because hyper-orgs stored every org in ONE shared
6// autobase — mounting it by bootstrap key replicated the whole thing.
7//
8// The fix: ONE autobase per org. A peer joins via a bearer invite that
9// carries only that org's autobase bootstrap key, so it replicates ONLY
10// that org. Other orgs live in separate autobases the joiner never
11// mounts.
12//
13// (An earlier "Test A" here demonstrated the bug by mounting the shared
14// bootstrap. That model no longer exists — HyperOrgs has no single
15// bootstrap key to mount — so the bug is now structurally impossible and
16// Test A was removed. The remaining test is the spec the fix satisfies.)
17
18import test from 'brittle'
19import Corestore from 'corestore'
20import { mkdtempSync } from 'fs'
21import { join } from 'path'
22import { tmpdir } from 'os'
23import { replicateAndSync } from 'autobase-test-helpers'
24import { HyperOrgs } from '../index.js'
25
26function tmpStore () {
27 return new Corestore(mkdtempSync(join(tmpdir(), 'hyper-orgs-scope-')))
28}
29
30test('#35: joinOrg(invite) replicates ONLY the invited org', async (t) => {
31 const storeA = tmpStore()
32 const storeB = tmpStore()
33
34 const owner = new HyperOrgs(storeA, { namespace: 'scope-owner' })
35 await owner.ready()
36 await owner.create('alpha', 'group', { displayName: 'Alpha' })
37 await owner.create('beta', 'group', { displayName: 'Beta' })
38 await owner.create('gamma', 'group', { displayName: 'Gamma' })
39 await owner.sync()
40
41 const { invitation: alphaInvite } = await owner.createOrgInvite('alpha', { role: 'reader' })
42 t.ok(alphaInvite, 'owner issued an alpha-scoped invite')
43
44 const joiner = new HyperOrgs(storeB, { namespace: 'scope-joiner' })
45 await joiner.ready()
46 await joiner.joinOrg(alphaInvite)
47
48 // Sync the joiner's single mounted autobase (alpha) against the owner's.
49 await replicateAndSync([owner.getOrgBase('alpha'), joiner.base])
50
51 t.ok(await joiner.get('alpha'), 'joiner sees alpha after joining its invite')
52
53 // The load-bearing assertions — beta/gamma live in separate autobases
54 // the joiner never mounted, so they MUST be invisible.
55 t.is(await joiner.get('beta'), null, 'SCOPE LEAK: joiner sees beta')
56 t.is(await joiner.get('gamma'), null, 'SCOPE LEAK: joiner sees gamma')
57
58 t.teardown(async () => {
59 try { await owner.close() } catch {}
60 try { await joiner.close() } catch {}
61 try { await storeA.close() } catch {}
62 try { await storeB.close() } catch {}
63 })
64})
test/writer-vs-reader.test.js +0 -79
……@@ -1,79 +0,0 @@
1// Layer-2 spec (issue #35): invite role gates WRITE access (model B —
2// owner enrolls the joiner). A contributor is enrolled as an autobase
3// writer and its appends replicate back to the owner; a reader is
4// recorded as read-only and cannot write, but can still read.
5//
6// Enrollment model B: the joiner mounts the org (read-only), hands its
7// writer key to an existing writer (the owner) — here passed directly,
8// standing in for the daemon's device-invite/pairing channel — and the
9// owner calls enrollWriter() with the invite's role.
10
11import test from 'brittle'
12import Corestore from 'corestore'
13import { mkdtempSync } from 'fs'
14import { join } from 'path'
15import { tmpdir } from 'os'
16import { replicateAndSync } from 'autobase-test-helpers'
17import { HyperOrgs } from '../index.js'
18
19function tmpStore () {
20 return new Corestore(mkdtempSync(join(tmpdir(), 'hyper-orgs-rw-')))
21}
22
23test('#35: contributor can write, reader cannot (owner-enroll, role-gated)', async (t) => {
24 const storeA = tmpStore()
25 const storeC = tmpStore()
26 const storeR = tmpStore()
27
28 const owner = new HyperOrgs(storeA, { namespace: 'rw-owner' })
29 await owner.ready()
30 await owner.create('proj', 'group', { displayName: 'Proj' })
31
32 const { invitation: contribInvite } = await owner.createOrgInvite('proj', { role: 'contributor' })
33 const { invitation: readerInvite } = await owner.createOrgInvite('proj', { role: 'reader' })
34
35 // ── contributor: join → owner enrolls its key → becomes a writer ──
36 const contrib = new HyperOrgs(storeC, { namespace: 'rw-contrib' })
37 await contrib.ready()
38 const cj = await contrib.joinOrg(contribInvite)
39 await replicateAndSync([owner.getOrgBase('proj'), contrib.getOrgBase('proj')])
40
41 // Owner approves (channel: cj.writerKey handed over). reader→mirror,
42 // contributor→writer.
43 await owner.enrollWriter('proj', cj.writerKey, cj.role)
44 await replicateAndSync([owner.getOrgBase('proj'), contrib.getOrgBase('proj')])
45 await replicateAndSync([owner.getOrgBase('proj'), contrib.getOrgBase('proj')])
46
47 t.ok(contrib.getOrgRoom('proj').writable, 'contributor is now an autobase writer')
48
49 await contrib.addResource('proj', 'repo-x')
50 await replicateAndSync([owner.getOrgBase('proj'), contrib.getOrgBase('proj')])
51
52 const ownerView = await owner.get('proj')
53 t.ok(ownerView.resources.includes('repo-x'), 'contributor write replicated to owner')
54
55 // ── reader: join → owner enrolls as read-only → cannot write ──
56 const reader = new HyperOrgs(storeR, { namespace: 'rw-reader' })
57 await reader.ready()
58 const rj = await reader.joinOrg(readerInvite)
59 await replicateAndSync([owner.getOrgBase('proj'), reader.getOrgBase('proj')])
60 await owner.enrollWriter('proj', rj.writerKey, rj.role) // role 'reader' → mirror
61 await replicateAndSync([owner.getOrgBase('proj'), reader.getOrgBase('proj')])
62
63 t.ok(await reader.get('proj'), 'reader can read the org')
64 t.absent(reader.getOrgRoom('proj').writable, 'reader is NOT an autobase writer')
65
66 // reader cannot write — append rejected (not writable)
67 let wrote = true
68 try { await reader.addResource('proj', 'sneaky-repo') } catch { wrote = false }
69 await replicateAndSync([owner.getOrgBase('proj'), reader.getOrgBase('proj')])
70
71 t.absent(wrote, 'reader.addResource was rejected locally (not writable)')
72 const after = await owner.get('proj')
73 t.absent(after.resources.includes('sneaky-repo'), 'reader write never reached the owner')
74
75 t.teardown(async () => {
76 for (const o of [owner, contrib, reader]) { try { await o.close() } catch {} }
77 for (const s of [storeA, storeC, storeR]) { try { await s.close() } catch {} }
78 })
79})