OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-orgs commits / 4a65f4e

Increment 2: owner-enroll write access (issue #35, model B)
joinOrg now returns the joiner's writer key; new enrollWriter(orgName, writerKey, role) lets an existing writer (the owner) approve a joiner — reader → read-only 'mirror', contributor+ → autobase 'writer'. This is the device-invite/pairing model: the joiner hands its key over, the owner approves; no insecure self-enroll. writer-vs-reader test green: contributor's write replicates to owner; reader can read but its write is rejected (not a writer). Full scope suite 3/3 (14 asserts) + invitations 10/10. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
4a65f4e09db6dc4db3a40ae473986efde1b45e35
by plankatron <fleeky@gmail.com> · 2026-05-24T23:48:23.000Z

Changed files (2) +74 -31

lib/hyper-orgs.js +46 -6
……@@ -200,10 +200,14 @@ export class HyperOrgs {
200200 }
201201
202202 /**
203 * Consume an org invite: mount ONLY that org's autobase as a
204 * read-only follower. (Write-enrollment is a follow-up increment.)
203 * Consume an org invite: mount ONLY that org's autobase. The joiner
204 * starts as a read-only follower; to gain write access it hands its
205 * returned `writerKey` to an existing writer (the owner) who calls
206 * enrollWriter() — the "owner auto-approves on connect" model (B).
207 * In the daemon that hand-off is the device-invite/pairing channel;
208 * in-process callers pass writerKey across directly.
205209 * @param {string} invitation
206 * @returns {Promise<string>} the org name now tracked
210 * @returns {Promise<{ name: string, writerKey: string, role: string }>}
207211 */
208212 async joinOrg (invitation) {
209213 const decoded = decodeInvite(invitation)
……@@ -212,7 +216,10 @@ export class HyperOrgs {
212216
213217 const name = decoded.name
214218 if (!name) throw new Error('org invite missing name (cannot key the org)')
215 if (this._orgs.has(name)) return name // already tracking
219 const role = decoded.roles[0] || 'reader'
220 if (this._orgs.has(name)) {
221 return { name, writerKey: this._orgs.get(name).room.localKeyHex, role }
222 }
216223
217224 const room = new Room(this._corestore, {
218225 schema: orgsSchema,
……@@ -223,8 +230,8 @@ export class HyperOrgs {
223230 })
224231 await room.ready()
225232 await room.base.update()
226 this._orgs.set(name, { room, role: decoded.roles[0] || 'reader', joined: true })
227 return name
233 this._orgs.set(name, { room, role, joined: true })
234 return { name, writerKey: room.localKeyHex, role }
228235 }
229236
230237 // ── Resources (per-org) ──────────────────────────────────────
……@@ -283,6 +290,39 @@ export class HyperOrgs {
283290 })
284291 }
285292
293 // ── Enrollment (owner-approves model, B) ─────────────────────
294
295 // Map an invite role to a Room writer role:
296 // reader → 'mirror' (replicates, NOT an autobase writer)
297 // contributor/maintainer/admin → 'writer' (can append)
298 // Only the org creator is the indexer ('owner'); finer per-op role
299 // enforcement (e.g. admin-only ops) is a later increment.
300 _roomRole (inviteRole) {
301 return inviteRole === 'reader' ? 'mirror' : 'writer'
302 }
303
304 /**
305 * Owner side of B: enroll a joiner's writer key with the role its
306 * invite granted. For write-capable roles this makes the joiner an
307 * autobase writer; for 'reader' it records membership but leaves them
308 * read-only. Caller must be an existing writer of the org.
309 * @param {string} orgName
310 * @param {string} writerKey the joiner's localKeyHex (from joinOrg)
311 * @param {string} [inviteRole='reader']
312 * @returns {Promise<string>} the member userId recorded
313 */
314 async enrollWriter (orgName, writerKey, inviteRole = 'reader') {
315 const room = this._requireRoom(orgName)
316 const userId = 'member-' + writerKey.slice(0, 16)
317 await room.addWriter(userId, {
318 writerKey,
319 role: this._roomRole(inviteRole),
320 addedBy: 'owner-enroll'
321 })
322 await room.base.update()
323 return userId
324 }
325
286326 // ── Writer management (per-org, delegated to Room) ───────────
287327
288328 async addWriter (orgName, userId, options = {}) {
test/writer-vs-reader.test.js +28 -25
……@@ -1,12 +1,12 @@
1// Layer-2 failing spec (issue #35): invite role bits gate WRITE access.
2// A contributor-joiner becomes a writer and its appends replicate back to
3// the owner; a reader-joiner's appends are dropped at apply (role check),
4// while it can still READ the org's data.
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.
55//
6// This is the Keet model: every joiner replicates the autobase, but the
7// apply layer enforces role per op. Reader role = read-only.
8//
9// Fails today: createOrgInvite/joinOrg + role-aware apply not implemented.
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.
1010
1111import test from 'brittle'
1212import Corestore from 'corestore'
……@@ -20,7 +20,7 @@ function tmpStore () {
2020 return new Corestore(mkdtempSync(join(tmpdir(), 'hyper-orgs-rw-')))
2121}
2222
23test('SPEC #35: contributor can write, reader cannot (role enforced at apply)', async (t) => {
23test('#35: contributor can write, reader cannot (owner-enroll, role-gated)', async (t) => {
2424 const storeA = tmpStore()
2525 const storeC = tmpStore()
2626 const storeR = tmpStore()
……@@ -28,46 +28,49 @@ test('SPEC #35: contributor can write, reader cannot (role enforced at apply)',
2828 const owner = new HyperOrgs(storeA, { namespace: 'rw-owner' })
2929 await owner.ready()
3030 await owner.create('proj', 'group', { displayName: 'Proj' })
31 await owner.base.update()
32
33 if (typeof owner.createOrgInvite !== 'function') {
34 t.fail('REGRESSION: HyperOrgs.createOrgInvite not implemented')
35 return
36 }
3731
3832 const { invitation: contribInvite } = await owner.createOrgInvite('proj', { role: 'contributor' })
3933 const { invitation: readerInvite } = await owner.createOrgInvite('proj', { role: 'reader' })
4034
41 // ── contributor ──────────────────────────────────────────────
35 // ── contributor: join → owner enrolls its key → becomes a writer ──
4236 const contrib = new HyperOrgs(storeC, { namespace: 'rw-contrib' })
4337 await contrib.ready()
44 await contrib.joinOrg(contribInvite)
45 // initial sync so the owner learns the joiner's writer key + enrolls it
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)
4644 await replicateAndSync([owner.getOrgBase('proj'), contrib.getOrgBase('proj')])
47 // (enrollment may require a couple of sync rounds; the impl decides)
4845 await replicateAndSync([owner.getOrgBase('proj'), contrib.getOrgBase('proj')])
4946
47 t.ok(contrib.getOrgRoom('proj').writable, 'contributor is now an autobase writer')
48
5049 await contrib.addResource('proj', 'repo-x')
5150 await replicateAndSync([owner.getOrgBase('proj'), contrib.getOrgBase('proj')])
5251
5352 const ownerView = await owner.get('proj')
5453 t.ok(ownerView.resources.includes('repo-x'), 'contributor write replicated to owner')
5554
56 // ── reader ───────────────────────────────────────────────────
55 // ── reader: join → owner enrolls as read-only → cannot write ──
5756 const reader = new HyperOrgs(storeR, { namespace: 'rw-reader' })
5857 await reader.ready()
59 await reader.joinOrg(readerInvite)
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
6061 await replicateAndSync([owner.getOrgBase('proj'), reader.getOrgBase('proj')])
6162
62 // reader can READ
6363 t.ok(await reader.get('proj'), 'reader can read the org')
64 t.absent(reader.getOrgRoom('proj').writable, 'reader is NOT an autobase writer')
6465
65 // reader CANNOT write — append must be dropped at apply
66 try { await reader.addResource('proj', 'sneaky-repo') } catch { /* may throw locally if not writable */ }
66 // reader cannot write — append rejected (not writable)
67 let wrote = true
68 try { await reader.addResource('proj', 'sneaky-repo') } catch { wrote = false }
6769 await replicateAndSync([owner.getOrgBase('proj'), reader.getOrgBase('proj')])
6870
71 t.absent(wrote, 'reader.addResource was rejected locally (not writable)')
6972 const after = await owner.get('proj')
70 t.absent(after.resources.includes('sneaky-repo'), 'reader write was rejected (role enforced)')
73 t.absent(after.resources.includes('sneaky-repo'), 'reader write never reached the owner')
7174
7275 t.teardown(async () => {
7376 for (const o of [owner, contrib, reader]) { try { await o.close() } catch {} }