OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-issues commits / f776737

Swap repo-tracker join/enroll onto blind-pairing (P2P invite, no HTTP)
HyperIssues.createInvite({ name, role, swarm }) now mints a hyper-rooms RoomPairing invite (repo name in meta) and holds the live pairing member; new joinRepoPairing(invite, { corestore, swarm }) factory mounts the repo Room over the swarm and, for contributor+, is addWritered DURING pairing — returning a HyperIssues over the mounted Room. Deleted the bearer-invite + owner-enroll (enrollFromInvite) path; addWriter() stays for mirror-mode HA coordination. close() now also tears down pairings. index re-exports decodeRoomPairingInvite (bearer encode/decode re-export retired). Tests: retired the in-memory repo-invite.test.js (removed API); added test/repo-pairing.test.js over a real testnet — contributor -> writer -> bidirectional, reader -> read-only mirror. Suite green: basic 18/18, repo-pairing 2/2, room-wrap 2/2. hyperswarm + @hyperswarm/testnet added as devDependencies (test-only; blind-pairing is transitive via hyper-rooms). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
f77673788d83f67b18ed7477f7aa528fa2d99885
by plankatron <fleeky@gmail.com> · 2026-05-27T19:49:06.000Z

Changed files (5) +161 -105

index.js +5 -4
……@@ -2,8 +2,9 @@
22 * hyper-issues — P2P issue tracking on hyper-rooms
33 */
44
5export { HyperIssues } from './lib/hyper-issues.js'
5export { HyperIssues, joinRepoPairing } 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'
7// Re-export the shared blind-pairing primitive so the daemon can mint/decode
8// repo invites without importing hyper-rooms directly. (The bearer-invite
9// encode/decode is retired — repo join is blind-pairing now.)
10export { decodeRoomPairingInvite, ROLES } from 'hyper-rooms'
lib/hyper-issues.js +59 -42
……@@ -18,7 +18,7 @@
1818 * tracker.localKey // exchange via P2P for addWriter on the inviter
1919 */
2020
21import { Room, encodeInvite, decodeInvite, isExpired } from 'hyper-rooms'
21import { Room, createRoomPairing, joinRoomPairing, decodeRoomPairingInvite } from 'hyper-rooms'
2222import { issuesSchema, DEFAULT_LABELS } from './schema.js'
2323
2424export class HyperIssues {
……@@ -49,6 +49,9 @@ export class HyperIssues {
4949 })
5050 this._ownsRoom = options.ownsRoom !== false
5151 }
52 // invite string -> live blind-pairing handle (held so outstanding repo
53 // invites keep accepting joiners and are closed with this tracker).
54 this._pairings = new Map()
5255 }
5356
5457 // ── Lifecycle / properties ──────────────────────────────────
……@@ -58,7 +61,13 @@ export class HyperIssues {
5861 }
5962
6063 async sync () { await this._room.base.update() }
61 async close () { if (this._ownsRoom) await this._room.close() }
64 async close () {
65 for (const pairing of this._pairings.values()) {
66 try { await pairing.close() } catch {}
67 }
68 this._pairings.clear()
69 if (this._ownsRoom) await this._room.close()
70 }
6271
6372 get key () { return this._room.key }
6473 get keyHex () { return this._room.keyHex }
……@@ -84,53 +93,32 @@ export class HyperIssues {
8493 return this._room.listWriters()
8594 }
8695
87 // ── Repo invites (issue #35) ─────────────────────────────────
88 // A repo tracker is already its own autobase. An invite carries THIS
89 // tracker's bootstrap key; a joiner mounts only this repo (peer mode)
90 // via `new HyperIssues(store, { bootstrapKey })`. Mirrors the
91 // hyper-orgs org-invite flow; uses the shared hyper-rooms primitive.
96 // ── Repo invites via blind-pairing (P2P, no HTTP) ────────────
97 // A repo tracker is its own autobase. createInvite mints a hyper-rooms
98 // RoomPairing invite (repo name in meta); a joiner pairs over the swarm
99 // and, for contributor+, is addWritered DURING pairing — see the
100 // joinRepoPairing() factory below. The old bearer-invite + owner-enroll
101 // (enrollFromInvite) path is retired; the write-grant rides the same P2P
102 // channel as discovery.
92103
93104 /**
105 * Host side. Returns a shareable invite + a close() for the live pairing
106 * member (also closed by this tracker's close()).
94107 * @param {object} opts
95 * @param {string} opts.name repo name (advisory label in the invite)
108 * @param {string} [opts.name] repo name (advisory; travels in meta)
96109 * @param {string} [opts.role='reader']
97 * @param {string} [opts.source] owner URL for write-enrollment
98 * @returns {{ invitation: string }}
110 * @param {object} opts.swarm shared Hyperswarm (injected)
111 * @returns {{ invitation: string, close: () => Promise<void> }}
99112 */
100113 createInvite (opts = {}) {
101 return {
102 invitation: encodeInvite({
103 bootstrapKey: this.keyHex,
104 kind: 'repo',
105 name: opts.name || null,
106 roles: [opts.role || 'reader'],
107 source: opts.source || null,
108 expiresAt: opts.expiresAt || null,
109 maxUses: opts.maxUses == null ? 1 : opts.maxUses
110 })
111 }
112 }
113
114 /**
115 * Owner side: a joiner presents {invitation, writerKey}; verify the
116 * invite is for THIS tracker, then enroll with its role (reader →
117 * read-only mirror, else → writer). Invite possession = authorization.
118 * @returns {Promise<{ name, role, userId }>}
119 */
120 async enrollFromInvite (invitation, writerKey) {
121 const d = decodeInvite(invitation)
122 if (d.kind !== 'repo') throw new Error(`invite is for "${d.kind}", not a repo`)
123 if (isExpired(d)) throw new Error('invite expired')
124 if (d.bootstrapKey !== this.keyHex) throw new Error('invite does not match this repo tracker')
125 const role = d.roles[0] || 'reader'
126 const userId = 'member-' + writerKey.slice(0, 16)
127 await this.addWriter(userId, {
128 writerKey,
129 role: role === 'reader' ? 'mirror' : 'writer',
130 addedBy: 'owner-enroll'
114 if (!opts.swarm) throw new Error('createInvite requires { swarm } (P2P pairing transport)')
115 const pairing = createRoomPairing(this._room, {
116 role: opts.role || 'reader',
117 swarm: opts.swarm,
118 meta: { kind: 'repo', name: opts.name || null }
131119 })
132 await this._room.base.update()
133 return { name: d.name, role, userId }
120 this._pairings.set(pairing.invite, pairing)
121 return { invitation: pairing.invite, close: pairing.close }
134122 }
135123
136124 async getWriter (userId) {
……@@ -517,3 +505,32 @@ export class HyperIssues {
517505 return entry ? entry.value.value : null
518506 }
519507}
508
509/**
510 * Candidate side: join a repo tracker via a blind-pairing invite (the P2P
511 * replacement for `new HyperIssues(store, { bootstrapKey })` + owner-enroll).
512 * Mounts the repo Room over the swarm, presents its localKeyHex, and for
513 * contributor+ becomes a writer DURING pairing. Returns a HyperIssues over
514 * the mounted Room.
515 * @param {string} invitation
516 * @param {object} opts
517 * @param {Corestore} opts.corestore
518 * @param {object} opts.swarm
519 * @param {string} [opts.namespace]
520 * @returns {Promise<{ tracker: HyperIssues, role: string, name: string|null }>}
521 */
522export async function joinRepoPairing (invitation, opts = {}) {
523 const { corestore, swarm } = opts
524 if (!corestore) throw new Error('joinRepoPairing requires { corestore }')
525 if (!swarm) throw new Error('joinRepoPairing requires { swarm }')
526 const dec = decodeRoomPairingInvite(invitation)
527 if (!dec.meta || dec.meta.kind !== 'repo') throw new Error('invite is not a repo pairing invite')
528 const { room, role, meta } = await joinRoomPairing(invitation, {
529 corestore,
530 swarm,
531 schema: issuesSchema,
532 namespace: opts.namespace || ('repo-join/' + dec.bootstrapKey.slice(0, 16))
533 })
534 const tracker = new HyperIssues(corestore, { room, ownsRoom: true })
535 return { tracker, role, name: meta && meta.name }
536}
package.json +3 -1
……@@ -11,8 +11,10 @@
1111 "hyper-rooms": "file:../hyper-rooms"
1212 },
1313 "devDependencies": {
14 "@hyperswarm/testnet": "^3.1.4",
1415 "autobase-test-helpers": "^2.0.0",
1516 "brittle": "^3.0.0",
16 "corestore": "^7.0.0"
17 "corestore": "^7.0.0",
18 "hyperswarm": "^4.8.0"
1719 }
1820}
test/repo-invite.test.js +0 -58
……@@ -1,58 +0,0 @@
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})
test/repo-pairing.test.js +94 -0
……@@ -0,0 +1,94 @@
1// Repo-tracker join + write-enrollment via blind-pairing over Hyperswarm
2// (the P2P replacement for the bearer invite + enrollFromInvite). Supersedes
3// the in-memory repo-invite.test.js, which exercised the now-removed API.
4//
5// Proves, over a real testnet:
6// - contributor: pairs → becomes a writer → its issue replicates to owner
7// - reader: pairs → read-only mirror (sees owner's issues, NOT a writer)
8
9import test from 'brittle'
10import Corestore from 'corestore'
11import Hyperswarm from 'hyperswarm'
12import createTestnet from '@hyperswarm/testnet'
13import { mkdtempSync } from 'fs'
14import { join } from 'path'
15import { tmpdir } from 'os'
16
17import { HyperIssues, joinRepoPairing } from '../index.js'
18
19let n = 0
20const tmp = () => mkdtempSync(join(tmpdir(), 'rp-' + (n++) + '-'))
21
22async function waitFor (pred, ms = 25000) {
23 const t0 = Date.now()
24 while (Date.now() - t0 < ms) {
25 try { if (await pred()) return true } catch {}
26 await new Promise((r) => setTimeout(r, 100))
27 }
28 return false
29}
30
31function makeNode (t, bootstrap) {
32 const store = new Corestore(tmp())
33 const swarm = new Hyperswarm({ bootstrap })
34 swarm.on('connection', (conn) => store.replicate(conn))
35 t.teardown(async () => { try { await swarm.destroy() } catch {} })
36 return { store, swarm }
37}
38
39test('contributor pairs into a repo tracker and becomes a writer (P2P, no HTTP)', async (t) => {
40 const testnet = await createTestnet(3, t.teardown)
41 const a = makeNode(t, testnet.bootstrap)
42 const b = makeNode(t, testnet.bootstrap)
43
44 const owner = new HyperIssues(a.store, { namespace: 'myrepo' })
45 await owner.ready()
46 t.teardown(async () => { try { await owner.close() } catch {} })
47 t.ok(owner.writable, 'owner (creator) is writable')
48 await owner.create({ title: 'owner issue', author: 'owner' })
49
50 const { invitation } = owner.createInvite({ name: 'myrepo', role: 'contributor', swarm: a.swarm })
51 t.ok(invitation.startsWith('roompair1:'), 'invite code minted')
52
53 const { tracker: joiner, role } = await joinRepoPairing(invitation, { corestore: b.store, swarm: b.swarm })
54 t.teardown(async () => { try { await joiner.close() } catch {} })
55 t.is(role, 'contributor', 'role echoed')
56 t.is(joiner.keyHex, owner.keyHex, 'joiner mounted the SAME tracker autobase')
57
58 const became = await waitFor(() => joiner.writable)
59 t.ok(became, 'contributor became a writer via pairing — over the swarm, no HTTP')
60
61 const id = await joiner.create({ title: 'joiner issue', author: 'joiner' })
62 const seen = await waitFor(async () => {
63 await owner.sync()
64 const iss = await owner.get(id)
65 return !!(iss && iss.title === 'joiner issue')
66 })
67 t.ok(seen, 'owner sees the joiner-authored issue (bidirectional)')
68})
69
70test('reader pairs into a repo tracker and stays a read-only mirror', async (t) => {
71 const testnet = await createTestnet(3, t.teardown)
72 const a = makeNode(t, testnet.bootstrap)
73 const b = makeNode(t, testnet.bootstrap)
74
75 const owner = new HyperIssues(a.store, { namespace: 'proj' })
76 await owner.ready()
77 t.teardown(async () => { try { await owner.close() } catch {} })
78 const id = await owner.create({ title: 'owner-only', author: 'owner' })
79
80 const { invitation } = owner.createInvite({ name: 'proj', role: 'reader', swarm: a.swarm })
81 const { tracker: reader, role } = await joinRepoPairing(invitation, { corestore: b.store, swarm: b.swarm })
82 t.teardown(async () => { try { await reader.close() } catch {} })
83 t.is(role, 'reader', 'role echoed as reader')
84
85 const sawRead = await waitFor(async () => {
86 await reader.sync()
87 const iss = await reader.get(id)
88 return !!(iss && iss.title === 'owner-only')
89 })
90 t.ok(sawRead, 'reader replicates the tracker read-only')
91
92 await new Promise((r) => setTimeout(r, 1500))
93 t.absent(reader.writable, 'reader is NOT enrolled as a writer')
94})