OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-orgs commits / ef2d9b4

Add enrollFromInvite — invite-authorized owner enroll (#35 item 2)
The daemon's enroll-writer path: a joiner presents {invitation, writerKey}; the owner verifies the invite refers to an org IT owns (created + bootstrapKey/name match), then enrolls with the invite's role. Possessing the invite is the authorization. Forged invites for unowned orgs are rejected. Suite 25/25, 72 asserts. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ef2d9b418d833fd70780946314ed89b9d651bcbc
by plankatron <fleeky@gmail.com> · 2026-05-25T00:40:39.000Z

Changed files (2) +69 -0

lib/hyper-orgs.js +22 -0
……@@ -517,6 +517,28 @@ export class HyperOrgs {
517517 return userId
518518 }
519519
520 /**
521 * Owner side, invite-authorized: a joiner presents the invite it holds
522 * + its writer key; we verify the invite refers to an org WE own
523 * (bootstrapKey + name match a tracked, created org), then enroll with
524 * the invite's role. Possessing a valid invite is the authorization —
525 * no prior membership required. This is what the daemon's enroll-writer
526 * RPC calls (the joiner's daemon hands these over via peer RPC).
527 * @returns {Promise<{ orgName, role, userId }>}
528 */
529 async enrollFromInvite (invitation, writerKey) {
530 const decoded = decodeInvite(invitation)
531 if (decoded.kind !== 'org') throw new Error(`invite is for "${decoded.kind}", not an org`)
532 if (isExpired(decoded)) throw new Error('invite expired')
533 const e = this._orgs.get(decoded.name)
534 if (!e || !e.created || e.room.keyHex !== decoded.bootstrapKey) {
535 throw new Error(`invite does not match an org owned by this instance`)
536 }
537 const role = decoded.roles[0] || 'reader'
538 const userId = await this.enrollWriter(decoded.name, writerKey, role)
539 return { orgName: decoded.name, role, userId }
540 }
541
520542 // ── Writer management (per-org, delegated to Room) ───────────
521543
522544 async addWriter (orgName, userId, options = {}) {
test/enroll-from-invite.test.js +47 -0
……@@ -0,0 +1,47 @@
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})