OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-issues commits / 262b47f

Repo invites: createInvite/enrollFromInvite on HyperIssues (#35 item 4)
A repo tracker is already its own autobase, so this is the invite/enroll WRAPPER (no shared-autobase split needed, unlike orgs). createInvite encodes this tracker's bootstrap key (kind:'repo'); a joiner mounts only this repo via new HyperIssues({ bootstrapKey }). enrollFromInvite: owner verifies the invite is for this tracker, enrolls reader→mirror / contributor→writer. Re-exports the hyper-rooms invite primitive so the daemon can decode without importing hyper-rooms directly. repo-invite test 8/8; suite 19/19, 77 asserts. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
262b47f13de893aa1659d630b213859aea3b0dbe
by plankatron <fleeky@gmail.com> · 2026-05-25T01:03:59.000Z

Changed files (3) +111 -1

index.js +3 -0
……@@ -4,3 +4,6 @@
44
55export { HyperIssues } from './lib/hyper-issues.js'
66export { issuesSchema, DEFAULT_LABELS } from './lib/schema.js'
7// Re-export the shared invite primitive so the daemon can encode/decode
8// repo invites without importing hyper-rooms directly.
9export { encodeInvite, decodeInvite, isExpired, ROLES } from 'hyper-rooms'
lib/hyper-issues.js +50 -1
……@@ -18,7 +18,7 @@
1818 * tracker.localKey // exchange via P2P for addWriter on the inviter
1919 */
2020
21import { Room } from 'hyper-rooms'
21import { Room, encodeInvite, decodeInvite, isExpired } from 'hyper-rooms'
2222import { issuesSchema, DEFAULT_LABELS } from './schema.js'
2323
2424export class HyperIssues {
……@@ -69,6 +69,55 @@ export class HyperIssues {
6969 return this._room.listWriters()
7070 }
7171
72 // ── Repo invites (issue #35) ─────────────────────────────────
73 // A repo tracker is already its own autobase. An invite carries THIS
74 // tracker's bootstrap key; a joiner mounts only this repo (peer mode)
75 // via `new HyperIssues(store, { bootstrapKey })`. Mirrors the
76 // hyper-orgs org-invite flow; uses the shared hyper-rooms primitive.
77
78 /**
79 * @param {object} opts
80 * @param {string} opts.name repo name (advisory label in the invite)
81 * @param {string} [opts.role='reader']
82 * @param {string} [opts.source] owner URL for write-enrollment
83 * @returns {{ invitation: string }}
84 */
85 createInvite (opts = {}) {
86 return {
87 invitation: encodeInvite({
88 bootstrapKey: this.keyHex,
89 kind: 'repo',
90 name: opts.name || null,
91 roles: [opts.role || 'reader'],
92 source: opts.source || null,
93 expiresAt: opts.expiresAt || null,
94 maxUses: opts.maxUses == null ? 1 : opts.maxUses
95 })
96 }
97 }
98
99 /**
100 * Owner side: a joiner presents {invitation, writerKey}; verify the
101 * invite is for THIS tracker, then enroll with its role (reader →
102 * read-only mirror, else → writer). Invite possession = authorization.
103 * @returns {Promise<{ name, role, userId }>}
104 */
105 async enrollFromInvite (invitation, writerKey) {
106 const d = decodeInvite(invitation)
107 if (d.kind !== 'repo') throw new Error(`invite is for "${d.kind}", not a repo`)
108 if (isExpired(d)) throw new Error('invite expired')
109 if (d.bootstrapKey !== this.keyHex) throw new Error('invite does not match this repo tracker')
110 const role = d.roles[0] || 'reader'
111 const userId = 'member-' + writerKey.slice(0, 16)
112 await this.addWriter(userId, {
113 writerKey,
114 role: role === 'reader' ? 'mirror' : 'writer',
115 addedBy: 'owner-enroll'
116 })
117 await this._room.base.update()
118 return { name: d.name, role, userId }
119 }
120
72121 async getWriter (userId) {
73122 return this._room.getWriter(userId)
74123 }
test/repo-invite.test.js +58 -0
……@@ -0,0 +1,58 @@
1// Repo invites (issue #35): a repo tracker is its own autobase. A peer
2// joins ONE repo via an invite carrying that tracker's bootstrap key
3// (peer mode). Owner enrolls: contributor → writer, reader → read-only.
4// Mirrors the hyper-orgs org-invite flow.
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 { HyperIssues, decodeInvite } from '../index.js'
13
14function tmpStore () {
15 return new Corestore(mkdtempSync(join(tmpdir(), 'hyper-issues-inv-')))
16}
17
18test('#35: repo invite — contributor writes, reader read-only', async (t) => {
19 const owner = new HyperIssues(tmpStore(), { namespace: 'repo-owner' })
20 await owner.ready()
21 const id = await owner.create({ title: 'Bug', body: 'details', author: 'alice' })
22
23 const { invitation } = owner.createInvite({ name: 'myrepo', role: 'contributor' })
24 const d = decodeInvite(invitation)
25 t.is(d.kind, 'repo')
26 t.is(d.name, 'myrepo')
27 t.is(d.bootstrapKey, owner.keyHex)
28
29 // Joiner mounts ONLY this repo tracker by its bootstrap key.
30 const joiner = new HyperIssues(tmpStore(), { namespace: 'repo-joiner', bootstrapKey: d.bootstrapKey })
31 await joiner.ready()
32 await replicateAndSync([owner.base, joiner.base])
33 t.ok(await joiner.get(id), 'joiner reads the existing issue')
34
35 // Owner enrolls the contributor (invite-authorized).
36 await owner.enrollFromInvite(invitation, joiner.localKeyHex)
37 await replicateAndSync([owner.base, joiner.base])
38 await replicateAndSync([owner.base, joiner.base])
39 t.ok(joiner.writable, 'contributor became a writer')
40
41 const id2 = await joiner.create({ title: 'From joiner', body: 'y', author: 'bob' })
42 await replicateAndSync([owner.base, joiner.base])
43 t.ok(await owner.get(id2), 'contributor write replicated to owner')
44
45 // Reader stays read-only.
46 const reader = new HyperIssues(tmpStore(), { namespace: 'repo-reader', bootstrapKey: d.bootstrapKey })
47 await reader.ready()
48 await replicateAndSync([owner.base, reader.base])
49 const { invitation: rInv } = owner.createInvite({ name: 'myrepo', role: 'reader' })
50 await owner.enrollFromInvite(rInv, reader.localKeyHex)
51 await replicateAndSync([owner.base, reader.base])
52 t.ok(await reader.get(id), 'reader can read')
53 t.absent(reader.writable, 'reader is NOT a writer')
54
55 t.teardown(async () => {
56 for (const x of [owner, joiner, reader]) { try { await x.close() } catch {} }
57 })
58})