| … | … | @@ -2,8 +2,9 @@ |
| 2 | 2 | * hyper-issues — P2P issue tracking on hyper-rooms |
| 3 | 3 | */ |
| 4 | 4 | |
| 5 | export { HyperIssues } from './lib/hyper-issues.js' | |
| 5 | export { HyperIssues, joinRepoPairing } from './lib/hyper-issues.js' | |
| 6 | 6 | export { 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. | |
| 9 | export { 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.) | |
| 10 | export { decodeRoomPairingInvite, ROLES } from 'hyper-rooms' |
| … | … | @@ -18,7 +18,7 @@ |
| 18 | 18 | * tracker.localKey // exchange via P2P for addWriter on the inviter |
| 19 | 19 | */ |
| 20 | 20 | |
| 21 | import { Room, encodeInvite, decodeInvite, isExpired } from 'hyper-rooms' | |
| 21 | import { Room, createRoomPairing, joinRoomPairing, decodeRoomPairingInvite } from 'hyper-rooms' | |
| 22 | 22 | import { issuesSchema, DEFAULT_LABELS } from './schema.js' |
| 23 | 23 | |
| 24 | 24 | export class HyperIssues { |
| … | … | @@ -49,6 +49,9 @@ export class HyperIssues { |
| 49 | 49 | }) |
| 50 | 50 | this._ownsRoom = options.ownsRoom !== false |
| 51 | 51 | } |
| 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() | |
| 52 | 55 | } |
| 53 | 56 | |
| 54 | 57 | // ── Lifecycle / properties ────────────────────────────────── |
| … | … | @@ -58,7 +61,13 @@ export class HyperIssues { |
| 58 | 61 | } |
| 59 | 62 | |
| 60 | 63 | 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 | } | |
| 62 | 71 | |
| 63 | 72 | get key () { return this._room.key } |
| 64 | 73 | get keyHex () { return this._room.keyHex } |
| … | … | @@ -84,53 +93,32 @@ export class HyperIssues { |
| 84 | 93 | return this._room.listWriters() |
| 85 | 94 | } |
| 86 | 95 | |
| 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. | |
| 92 | 103 | |
| 93 | 104 | /** |
| 105 | * Host side. Returns a shareable invite + a close() for the live pairing | |
| 106 | * member (also closed by this tracker's close()). | |
| 94 | 107 | * @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) | |
| 96 | 109 | * @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> }} | |
| 99 | 112 | */ |
| 100 | 113 | 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 } | |
| 131 | 119 | }) |
| 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 } | |
| 134 | 122 | } |
| 135 | 123 | |
| 136 | 124 | async getWriter (userId) { |
| … | … | @@ -517,3 +505,32 @@ export class HyperIssues { |
| 517 | 505 | return entry ? entry.value.value : null |
| 518 | 506 | } |
| 519 | 507 | } |
| 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 | */ | |
| 522 | export 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 | } |
| … | … | @@ -11,8 +11,10 @@ |
| 11 | 11 | "hyper-rooms": "file:../hyper-rooms" |
| 12 | 12 | }, |
| 13 | 13 | "devDependencies": { |
| 14 | "@hyperswarm/testnet": "^3.1.4", | |
| 14 | 15 | "autobase-test-helpers": "^2.0.0", |
| 15 | 16 | "brittle": "^3.0.0", |
| 16 | "corestore": "^7.0.0" | |
| 17 | "corestore": "^7.0.0", | |
| 18 | "hyperswarm": "^4.8.0" | |
| 17 | 19 | } |
| 18 | 20 | } |
| … | … | @@ -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 | ||
| 6 | import test from 'brittle' | |
| 7 | import Corestore from 'corestore' | |
| 8 | import { mkdtempSync } from 'fs' | |
| 9 | import { join } from 'path' | |
| 10 | import { tmpdir } from 'os' | |
| 11 | import { replicateAndSync } from 'autobase-test-helpers' | |
| 12 | import { HyperIssues, decodeInvite } from '../index.js' | |
| 13 | ||
| 14 | function tmpStore () { | |
| 15 | return new Corestore(mkdtempSync(join(tmpdir(), 'hyper-issues-inv-'))) | |
| 16 | } | |
| 17 | ||
| 18 | test('#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 | }) |
| … | … | @@ -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 | ||
| 9 | import test from 'brittle' | |
| 10 | import Corestore from 'corestore' | |
| 11 | import Hyperswarm from 'hyperswarm' | |
| 12 | import createTestnet from '@hyperswarm/testnet' | |
| 13 | import { mkdtempSync } from 'fs' | |
| 14 | import { join } from 'path' | |
| 15 | import { tmpdir } from 'os' | |
| 16 | ||
| 17 | import { HyperIssues, joinRepoPairing } from '../index.js' | |
| 18 | ||
| 19 | let n = 0 | |
| 20 | const tmp = () => mkdtempSync(join(tmpdir(), 'rp-' + (n++) + '-')) | |
| 21 | ||
| 22 | async 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 | ||
| 31 | function 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 | ||
| 39 | test('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 | ||
| 70 | test('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 | }) |