| … | … | @@ -4,3 +4,6 @@ |
| 4 | 4 | |
| 5 | 5 | export { HyperIssues } 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' |
| … | … | @@ -18,7 +18,7 @@ |
| 18 | 18 | * tracker.localKey // exchange via P2P for addWriter on the inviter |
| 19 | 19 | */ |
| 20 | 20 | |
| 21 | import { Room } from 'hyper-rooms' | |
| 21 | import { Room, encodeInvite, decodeInvite, isExpired } from 'hyper-rooms' | |
| 22 | 22 | import { issuesSchema, DEFAULT_LABELS } from './schema.js' |
| 23 | 23 | |
| 24 | 24 | export class HyperIssues { |
| … | … | @@ -69,6 +69,55 @@ export class HyperIssues { |
| 69 | 69 | return this._room.listWriters() |
| 70 | 70 | } |
| 71 | 71 | |
| 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 | ||
| 72 | 121 | async getWriter (userId) { |
| 73 | 122 | return this._room.getWriter(userId) |
| 74 | 123 | } |
| … | … | @@ -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 | ||
| 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 | }) |