| … | … | @@ -24,7 +24,7 @@ |
| 24 | 24 | * today joiners are read-only followers of the org's autobase. |
| 25 | 25 | */ |
| 26 | 26 | |
| 27 | import { Room, composeSchemas, encodeInvite, decodeInvite, isExpired } from 'hyper-rooms' | |
| 27 | import { Room, composeSchemas, createRoomPairing, joinRoomPairing, decodeRoomPairingInvite } from 'hyper-rooms' | |
| 28 | 28 | import { orgsSchema, validateName } from './schema.js' |
| 29 | 29 | |
| 30 | 30 | export class HyperOrgs { |
| … | … | @@ -45,6 +45,9 @@ export class HyperOrgs { |
| 45 | 45 | : orgsSchema |
| 46 | 46 | // name -> { room: Room, role: string, created: boolean } |
| 47 | 47 | 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() | |
| 48 | 51 | // Local append-only index (NOT replicated): which orgs this instance |
| 49 | 52 | // tracks + how to re-open each Room on restart. {op,name,bootstrapKey, |
| 50 | 53 | // role,created}. Last-write-wins per name; op:'untrack' drops. |
| … | … | @@ -111,6 +114,10 @@ export class HyperOrgs { |
| 111 | 114 | } |
| 112 | 115 | |
| 113 | 116 | async close () { |
| 117 | for (const pairing of this._pairings.values()) { | |
| 118 | try { await pairing.close() } catch {} | |
| 119 | } | |
| 120 | this._pairings.clear() | |
| 114 | 121 | for (const { room } of this._orgs.values()) { |
| 115 | 122 | try { await room.close() } catch {} |
| 116 | 123 | } |
| … | … | @@ -238,47 +245,59 @@ export class HyperOrgs { |
| 238 | 245 | // ── Invites / join (the per-org-autobase scope mechanism) ──── |
| 239 | 246 | |
| 240 | 247 | /** |
| 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). | |
| 243 | 254 | * @param {string} name |
| 244 | * @param {object} [opts] | |
| 255 | * @param {object} opts | |
| 245 | 256 | * @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> }>} | |
| 249 | 259 | */ |
| 250 | 260 | async createOrgInvite (name, opts = {}) { |
| 251 | 261 | 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 } | |
| 260 | 267 | }) |
| 261 | return { invitation } | |
| 268 | this._pairings.set(pairing.invite, pairing) | |
| 269 | return { invitation: pairing.invite, close: pairing.close } | |
| 262 | 270 | } |
| 263 | 271 | |
| 264 | 272 | /** |
| 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. | |
| 271 | 277 | * @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 }>} | |
| 273 | 281 | */ |
| 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 } | |
| 282 | 301 | } |
| 283 | 302 | |
| 284 | 303 | /** |
| … | … | @@ -511,60 +530,11 @@ export class HyperOrgs { |
| 511 | 530 | return false |
| 512 | 531 | } |
| 513 | 532 | |
| 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. | |
| 568 | 538 | |
| 569 | 539 | // ── Writer management (per-org, delegated to Room) ─────────── |
| 570 | 540 |
| … | … | @@ -11,9 +11,11 @@ |
| 11 | 11 | "hyper-rooms": "file:../hyper-rooms" |
| 12 | 12 | }, |
| 13 | 13 | "devDependencies": { |
| 14 | "@hyperswarm/testnet": "^3.1.4", | |
| 14 | 15 | "autobase-test-helpers": "^2.0.0", |
| 15 | 16 | "brittle": "^3.0.0", |
| 16 | 17 | "corestore": "^7.0.0", |
| 17 | "hyper-issues": "file:../hyper-issues" | |
| 18 | "hyper-issues": "file:../hyper-issues", | |
| 19 | "hyperswarm": "^4.8.0" | |
| 18 | 20 | } |
| 19 | 21 | } |
| … | … | @@ -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 | ||
| 6 | import test from 'brittle' | |
| 7 | import Corestore from 'corestore' | |
| 8 | import { mkdtempSync } from 'fs' | |
| 9 | import { join } from 'path' | |
| 10 | import { tmpdir } from 'os' | |
| 11 | import { replicateAndSync } from 'autobase-test-helpers' | |
| 12 | import { HyperOrgs } from '../index.js' | |
| 13 | ||
| 14 | function tmpStore () { | |
| 15 | return new Corestore(mkdtempSync(join(tmpdir(), 'hyper-orgs-efi-'))) | |
| 16 | } | |
| 17 | ||
| 18 | test('#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 | }) |
| … | … | @@ -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 | ||
| 8 | import test from 'brittle' | |
| 9 | import Corestore from 'corestore' | |
| 10 | import { mkdtempSync } from 'fs' | |
| 11 | import { join } from 'path' | |
| 12 | import { tmpdir } from 'os' | |
| 13 | import { replicateAndSync } from 'autobase-test-helpers' | |
| 14 | import { HyperOrgs } from '../index.js' | |
| 15 | ||
| 16 | function tmpStore () { | |
| 17 | return new Corestore(mkdtempSync(join(tmpdir(), 'hyper-orgs-multi-'))) | |
| 18 | } | |
| 19 | ||
| 20 | test('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 | }) |
| … | … | @@ -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 | ||
| 12 | import test from 'brittle' | |
| 13 | import Corestore from 'corestore' | |
| 14 | import Hyperswarm from 'hyperswarm' | |
| 15 | import createTestnet from '@hyperswarm/testnet' | |
| 16 | import { mkdtempSync } from 'fs' | |
| 17 | import { join } from 'path' | |
| 18 | import { tmpdir } from 'os' | |
| 19 | ||
| 20 | import { HyperOrgs } from '../index.js' | |
| 21 | ||
| 22 | let n = 0 | |
| 23 | const tmp = () => mkdtempSync(join(tmpdir(), 'op-' + (n++) + '-')) | |
| 24 | ||
| 25 | async 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 | ||
| 34 | async 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 | ||
| 44 | test('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 | ||
| 73 | test('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 | ||
| 97 | test('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 | }) |
| … | … | @@ -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 | ||
| 18 | import test from 'brittle' | |
| 19 | import Corestore from 'corestore' | |
| 20 | import { mkdtempSync } from 'fs' | |
| 21 | import { join } from 'path' | |
| 22 | import { tmpdir } from 'os' | |
| 23 | import { replicateAndSync } from 'autobase-test-helpers' | |
| 24 | import { HyperOrgs } from '../index.js' | |
| 25 | ||
| 26 | function tmpStore () { | |
| 27 | return new Corestore(mkdtempSync(join(tmpdir(), 'hyper-orgs-scope-'))) | |
| 28 | } | |
| 29 | ||
| 30 | test('#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 | }) |
| … | … | @@ -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 | ||
| 11 | import test from 'brittle' | |
| 12 | import Corestore from 'corestore' | |
| 13 | import { mkdtempSync } from 'fs' | |
| 14 | import { join } from 'path' | |
| 15 | import { tmpdir } from 'os' | |
| 16 | import { replicateAndSync } from 'autobase-test-helpers' | |
| 17 | import { HyperOrgs } from '../index.js' | |
| 18 | ||
| 19 | function tmpStore () { | |
| 20 | return new Corestore(mkdtempSync(join(tmpdir(), 'hyper-orgs-rw-'))) | |
| 21 | } | |
| 22 | ||
| 23 | test('#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 | }) |