| … | … | @@ -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. |
| 5 | 5 | // |
| 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. |
| 10 | 10 | |
| 11 | 11 | import test from 'brittle' |
| 12 | 12 | import Corestore from 'corestore' |
| … | … | @@ -20,7 +20,7 @@ function tmpStore () { |
| 20 | 20 | return new Corestore(mkdtempSync(join(tmpdir(), 'hyper-orgs-rw-'))) |
| 21 | 21 | } |
| 22 | 22 | |
| 23 | | test('SPEC #35: contributor can write, reader cannot (role enforced at apply)', async (t) => { |
| 23 | test('#35: contributor can write, reader cannot (owner-enroll, role-gated)', async (t) => { |
| 24 | 24 | const storeA = tmpStore() |
| 25 | 25 | const storeC = tmpStore() |
| 26 | 26 | const storeR = tmpStore() |
| … | … | @@ -28,46 +28,49 @@ test('SPEC #35: contributor can write, reader cannot (role enforced at apply)', |
| 28 | 28 | const owner = new HyperOrgs(storeA, { namespace: 'rw-owner' }) |
| 29 | 29 | await owner.ready() |
| 30 | 30 | 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 | | } |
| 37 | 31 | |
| 38 | 32 | const { invitation: contribInvite } = await owner.createOrgInvite('proj', { role: 'contributor' }) |
| 39 | 33 | const { invitation: readerInvite } = await owner.createOrgInvite('proj', { role: 'reader' }) |
| 40 | 34 | |
| 41 | | // ── contributor ────────────────────────────────────────────── |
| 35 | // ── contributor: join → owner enrolls its key → becomes a writer ── |
| 42 | 36 | const contrib = new HyperOrgs(storeC, { namespace: 'rw-contrib' }) |
| 43 | 37 | 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) |
| 46 | 44 | await replicateAndSync([owner.getOrgBase('proj'), contrib.getOrgBase('proj')]) |
| 47 | | // (enrollment may require a couple of sync rounds; the impl decides) |
| 48 | 45 | await replicateAndSync([owner.getOrgBase('proj'), contrib.getOrgBase('proj')]) |
| 49 | 46 | |
| 47 | t.ok(contrib.getOrgRoom('proj').writable, 'contributor is now an autobase writer') |
| 48 | |
| 50 | 49 | await contrib.addResource('proj', 'repo-x') |
| 51 | 50 | await replicateAndSync([owner.getOrgBase('proj'), contrib.getOrgBase('proj')]) |
| 52 | 51 | |
| 53 | 52 | const ownerView = await owner.get('proj') |
| 54 | 53 | t.ok(ownerView.resources.includes('repo-x'), 'contributor write replicated to owner') |
| 55 | 54 | |
| 56 | | // ── reader ─────────────────────────────────────────────────── |
| 55 | // ── reader: join → owner enrolls as read-only → cannot write ── |
| 57 | 56 | const reader = new HyperOrgs(storeR, { namespace: 'rw-reader' }) |
| 58 | 57 | 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 |
| 60 | 61 | await replicateAndSync([owner.getOrgBase('proj'), reader.getOrgBase('proj')]) |
| 61 | 62 | |
| 62 | | // reader can READ |
| 63 | 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') |
| 64 | 65 | |
| 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 } |
| 67 | 69 | await replicateAndSync([owner.getOrgBase('proj'), reader.getOrgBase('proj')]) |
| 68 | 70 | |
| 71 | t.absent(wrote, 'reader.addResource was rejected locally (not writable)') |
| 69 | 72 | 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') |
| 71 | 74 | |
| 72 | 75 | t.teardown(async () => { |
| 73 | 76 | for (const o of [owner, contrib, reader]) { try { await o.close() } catch {} } |