/**
* Server Linker — Blind-pairing transport for server identity transfer
*
* Transfers server mnemonic + deploy metadata from an existing pear-git
* server to a new one. Both servers end up with the same identity,
* so they produce identical punch:// and pear:// URLs.
*
* Flow:
* Source server (create-invite): exports mnemonic, waits for peer
* Target server (join): receives mnemonic + metadata, reconstructs identity
*/
import Hyperswarm from 'hyperswarm'
import BlindPairing from 'blind-pairing'
import crypto from 'hypercore-crypto'
export class ServerLinker {
constructor ({ serverIdentity, storagePath, swarm }) {
this.serverIdentity = serverIdentity || null
this.storagePath = storagePath || null
this._externalSwarm = swarm || null
this._swarm = null
this._pairing = null
this._timeout = null
}
/**
* Create an invite for server linking (source server)
*
* @param {Object} metadata
* @param {string[]} metadata.repoList - List of repo names
* @param {Object} metadata.deployKeys - Map of repo name -> pear:// URL
* @returns {Promise<{ invite: Buffer, inviteHex: string, waitForPeer: function }>}
*/
async createInvite (metadata = {}) {
if (!this.serverIdentity) {
throw new Error('Server identity required to create invite')
}
const mnemonic = this.serverIdentity.toMnemonic()
const payload = {
mnemonic,
serverKey: this.serverIdentity.getPublicKeyHex(),
repoList: metadata.repoList || [],
deployKeys: metadata.deployKeys || {},
deployRegistryKey: metadata.deployRegistryKey || null,
createdAt: Date.now()
}
const payloadBuf = Buffer.from(JSON.stringify(payload), 'utf-8')
const pairingKey = crypto.keyPair()
const inviteResult = BlindPairing.createInvite(pairingKey.publicKey)
const { invite, publicKey: invitePublicKey, discoveryKey, seed: inviteSeed } = inviteResult
const inviteKeyPair = crypto.keyPair(inviteSeed)
this._swarm = this._externalSwarm || new Hyperswarm()
this._pairing = new BlindPairing(this._swarm)
const peerConnected = new Promise((resolve, reject) => {
this._timeout = setTimeout(() => {
reject(new Error('Timeout waiting for target server'))
}, 10 * 60 * 1000)
this._pairing.addMember({
discoveryKey,
onadd: (request) => {
clearTimeout(this._timeout)
this._timeout = null
try {
const userData = request.open(invitePublicKey)
request.confirm({
key: pairingKey.publicKey,
encryptionKey: null,
additional: {
data: payloadBuf,
signature: crypto.sign(payloadBuf, inviteKeyPair.secretKey)
}
})
resolve({
targetServerKey: userData ? userData.toString('hex') : null
})
} catch (err) {
reject(err)
}
}
})
})
return {
invite,
inviteHex: invite.toString('hex'),
waitForPeer: () => peerConnected
}
}
/**
* Join with an invite (target server)
*
* @param {string} inviteHex - Hex-encoded invite from source server
* @returns {Promise<{ mnemonic, serverKey, repoList, deployKeys }>}
*/
async joinWithInvite (inviteHex) {
const inviteBuf = Buffer.from(inviteHex, 'hex')
this._swarm = this._externalSwarm || new Hyperswarm()
this._pairing = new BlindPairing(this._swarm)
const userData = this.storagePath
? Buffer.from(this.storagePath, 'utf-8')
: Buffer.alloc(0)
const result = await new Promise((resolve, reject) => {
this._timeout = setTimeout(() => {
reject(new Error('Timeout waiting for source server'))
}, 10 * 60 * 1000)
this._pairing.addCandidate({
invite: inviteBuf,
userData,
onadd: (auth) => {
clearTimeout(this._timeout)
this._timeout = null
try {
if (!auth.data) {
reject(new Error('No payload received from source server'))
return
}
const payload = JSON.parse(auth.data.toString('utf-8'))
resolve(payload)
} catch (err) {
reject(err)
}
}
})
})
return result
}
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 ServerLinker