/**
* Repo Linker — Blind-pairing transport for bootstrap key exchange
*
* Uses blind-pairing over Hyperswarm to securely exchange Autobase
* bootstrap keys between pear-git servers. Follows DeviceLinker pattern
* exactly — owner creates invite, joiner connects with invite.
*
* Flow:
* Owner: creates invite, sends { bootstrapKey, repoName, role }
* Joiner: connects with invite, sends { userId, serverKey, identityKey, proof }
* After pairing: owner appends add-writer to Autobase
*
* This enables multi-server collaborative repos without sharing
* raw Autobase keys out-of-band.
*/
import Hyperswarm from 'hyperswarm'
import BlindPairing from 'blind-pairing'
import crypto from 'hypercore-crypto'
export class RepoLinker {
constructor ({ swarm, bootstrap } = {}) {
this._externalSwarm = swarm || null
this._bootstrap = bootstrap || null
this._swarm = null
this._pairing = null
this._timeout = null
}
/**
* Create an invite for repo linking (owner side)
*
* The owner has an existing LinkedRepo and wants to add a writer.
* The payload is sent to the joiner after they connect.
*
* @param {Object} payload - Data to send to joiner
* @param {string} payload.bootstrapKey - Autobase bootstrap key (hex)
* @param {string} payload.repoName - Repository name
* @param {string} payload.role - Role to grant ('writer' or 'mirror')
* @returns {Promise<{ invite: Buffer, inviteHex: string, waitForPeer: function }>}
*/
async createInvite (payload) {
const payloadBuf = Buffer.from(JSON.stringify(payload), 'utf-8')
// Generate key for discovery
const pairingKey = crypto.keyPair()
// Create invite
const inviteResult = BlindPairing.createInvite(pairingKey.publicKey)
const { invite, publicKey: invitePublicKey, seed: inviteSeed } = inviteResult
// Derive invite's full keypair from seed (needed for signing)
const inviteKeyPair = crypto.keyPair(inviteSeed)
// Create ephemeral swarm (use bootstrap for testnet support)
this._swarm = this._externalSwarm || new Hyperswarm(this._bootstrap ? { bootstrap: this._bootstrap } : {})
this._pairing = new BlindPairing(this._swarm)
const peerConnected = new Promise((resolve, reject) => {
this._timeout = setTimeout(() => {
reject(new Error('Timeout waiting for peer'))
}, 5 * 60 * 1000) // 5 minute timeout
this._pairing.addMember({
discoveryKey: inviteResult.discoveryKey,
onadd: (request) => {
clearTimeout(this._timeout)
this._timeout = null
try {
// Open the request — userData contains joiner's info
const joinerDataBuf = request.open(invitePublicKey)
let joinerInfo = null
if (joinerDataBuf && joinerDataBuf.length > 0) {
try {
joinerInfo = JSON.parse(joinerDataBuf.toString('utf-8'))
} catch {
joinerInfo = { raw: joinerDataBuf.toString('hex') }
}
}
// Confirm with payload (bootstrapKey + repoName + role)
request.confirm({
key: pairingKey.publicKey,
encryptionKey: null,
additional: {
data: payloadBuf,
signature: crypto.sign(payloadBuf, inviteKeyPair.secretKey)
}
})
resolve(joinerInfo)
} catch (err) {
reject(err)
}
}
})
})
return {
invite,
inviteHex: invite.toString('hex'),
waitForPeer: () => peerConnected
}
}
/**
* Join with an invite (joiner side)
*
* The joiner connects to the owner, sends their identity info,
* and receives the Autobase bootstrap key.
*
* @param {string} inviteHex - Hex-encoded invite from owner
* @param {Object} joinerInfo - Joiner's identity info to send to owner
* @param {string} joinerInfo.userId - User ID
* @param {string} joinerInfo.serverKey - Server's Noise key (hex)
* @param {string} [joinerInfo.identityKey] - Keet identity key (hex)
* @param {string} [joinerInfo.proof] - Identity proof
* @returns {Promise<{ bootstrapKey: string, repoName: string, role: string }>}
*/
async joinWithInvite (inviteHex, joinerInfo) {
const inviteBuf = Buffer.from(inviteHex, 'hex')
const joinerDataBuf = Buffer.from(JSON.stringify(joinerInfo), 'utf-8')
// Create ephemeral swarm (use bootstrap for testnet support)
this._swarm = this._externalSwarm || new Hyperswarm(this._bootstrap ? { bootstrap: this._bootstrap } : {})
this._pairing = new BlindPairing(this._swarm)
const result = await new Promise((resolve, reject) => {
this._timeout = setTimeout(() => {
reject(new Error('Timeout waiting for owner'))
}, 5 * 60 * 1000)
this._pairing.addCandidate({
invite: inviteBuf,
userData: joinerDataBuf,
onadd: (auth) => {
clearTimeout(this._timeout)
this._timeout = null
try {
const dataBuf = auth.data
if (!dataBuf) {
reject(new Error('No payload received from owner'))
return
}
const payload = JSON.parse(dataBuf.toString('utf-8'))
resolve(payload)
} catch (err) {
reject(err)
}
}
})
})
return result
}
/**
* Close ephemeral swarm and pairing
*/
async close () {
if (this._timeout) {
clearTimeout(this._timeout)
this._timeout = null
}
if (this._pairing) {
await this._pairing.close()
this._pairing = null
}
if (this._swarm && !this._externalSwarm) {
await this._swarm.destroy()
}
this._swarm = null
}
}
export default RepoLinker