/**
* Links two hypernote instances using blind-pairing.
*
* Follows pear-git's RepoLinker pattern exactly:
* - Fresh pairing keypair for discovery
* - Invite seed → keypair for signing additional.data
* - Payload (bootstrapKey + writerKey) sent as signed additional.data
* - Ephemeral swarm destroyed after pairing
*
* Flow:
* 1. Instance A creates invite (shares registry bootstrap key + writer key)
* 2. Instance B joins with invite hex, sends own writer key as userData
* 3. Instance A receives B's writer key, confirms with signed payload
* 4. Instance B receives A's payload (bootstrapKey + writerKey) via auth.data
* 5. Both add each other as Autobase writers
*/
import BlindPairing from 'blind-pairing'
import Hyperswarm from 'hyperswarm'
import crypto from 'hypercore-crypto'
export class InstanceLinker {
constructor (sharedRegistry, opts = {}) {
this._registry = sharedRegistry
this._externalSwarm = opts.swarm || null
this._instanceKey = opts.instanceKey || null
this._instanceUrl = opts.instanceUrl || null
this._callbackToken = opts.callbackToken || null
this._swarm = null
this._pairing = null
this._timeout = null
}
/**
* Create an invite for instance linking (source side).
*
* Sends registry bootstrapKey + local writerKey as signed payload.
* Returns invite hex for the joiner and a waitForPeer() promise.
*/
async createInvite () {
const payload = {
bootstrapKey: this._registry.bootstrapKey.toString('hex'),
writerKey: this._registry.localWriterKey.toString('hex'),
instanceKey: this._instanceKey,
instanceUrl: this._instanceUrl,
callbackToken: this._callbackToken
}
const payloadBuf = Buffer.from(JSON.stringify(payload), 'utf-8')
// Fresh keypair for discovery (NOT the registry key)
const pairingKey = crypto.keyPair()
// Create invite from the pairing key
const inviteResult = BlindPairing.createInvite(pairingKey.publicKey)
const { invite, publicKey: invitePublicKey, discoveryKey, seed: inviteSeed } = inviteResult
// Derive full keypair from invite seed (for signing additional.data)
const inviteKeyPair = crypto.keyPair(inviteSeed)
// Ephemeral swarm for pairing
this._swarm = this._externalSwarm || new Hyperswarm()
this._pairing = new BlindPairing(this._swarm)
console.log('[InstanceLinker] Creating invite...')
console.log('[InstanceLinker] Discovery key:', discoveryKey.toString('hex').substring(0, 16) + '...')
console.log('[InstanceLinker] Invite hex length:', invite.length)
// Log swarm connectivity
this._swarm.on('connection', (conn, info) => {
console.log('[InstanceLinker] Swarm connection from peer:', info.publicKey.toString('hex').substring(0, 16) + '...')
})
const peerConnected = new Promise((resolve, reject) => {
this._timeout = setTimeout(() => {
console.error('[InstanceLinker] TIMEOUT — no peer connected in 5 minutes')
reject(new Error('Timeout waiting for peer to link'))
}, 5 * 60 * 1000)
const member = this._pairing.addMember({
discoveryKey,
onadd: (request) => {
console.log('[InstanceLinker] Member onadd fired — peer found!')
clearTimeout(this._timeout)
this._timeout = null
try {
// Open 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 signed payload (bootstrapKey + writerKey + instanceUrl)
request.confirm({
key: pairingKey.publicKey,
encryptionKey: null,
additional: {
data: payloadBuf,
signature: crypto.sign(payloadBuf, inviteKeyPair.secretKey)
}
})
// Admit the joiner's writer key NOW, bound to this authenticated
// pairing handshake (their key is stable/bootstrap-independent and
// arrived as signed userData). Replaces the HTTP callback token.
if (joinerInfo && joinerInfo.writerKey) {
this._registry.addWriter(joinerInfo.writerKey).catch((err) => {
console.error('[InstanceLinker] addWriter during pairing failed:', err.message)
})
}
resolve(joinerInfo)
} catch (err) {
reject(err)
}
}
})
})
// Catch timeout silently — invite expiry is not an error
peerConnected.catch(() => {})
return {
inviteHex: invite.toString('hex'),
waitForPeer: () => peerConnected
}
}
/**
* Join with an invite (joiner side).
*
* Sends own writerKey as userData, receives source's bootstrapKey + writerKey
* via auth.data (the signed additional payload).
*/
async joinWithInvite (inviteHex) {
const inviteBuf = Buffer.from(inviteHex, 'hex')
// Send instanceKey + our STABLE writer key as userData. The writer key is
// bootstrap-independent (deterministic keypair), so it is the same before
// and after re-init — the source can admit it over THIS authenticated
// handshake instead of an unauthenticated HTTP callback.
const joinerInfo = {
instanceKey: this._instanceKey,
writerKey: this._registry.localWriterKey.toString('hex')
}
const joinerDataBuf = Buffer.from(JSON.stringify(joinerInfo), 'utf-8')
// Ephemeral swarm for pairing
this._swarm = this._externalSwarm || new Hyperswarm()
this._pairing = new BlindPairing(this._swarm)
console.log('[InstanceLinker] Joining with invite hex length:', inviteBuf.length)
console.log('[InstanceLinker] Sending instanceKey:', (joinerInfo.instanceKey || 'none').substring(0, 16) + '...')
// Log swarm connectivity
this._swarm.on('connection', (conn, info) => {
console.log('[InstanceLinker] Swarm connection from peer:', info.publicKey.toString('hex').substring(0, 16) + '...')
})
const result = await new Promise((resolve, reject) => {
this._timeout = setTimeout(() => {
console.error('[InstanceLinker] TIMEOUT — could not reach source in 5 minutes')
reject(new Error('Timeout waiting for source instance'))
}, 5 * 60 * 1000)
this._pairing.addCandidate({
invite: inviteBuf,
userData: joinerDataBuf,
onadd: (auth) => {
console.log('[InstanceLinker] Candidate onadd fired — paired!')
clearTimeout(this._timeout)
this._timeout = null
try {
// auth.data contains the signed payload from the source
if (!auth.data || auth.data.length === 0) {
reject(new Error('No payload received from source instance'))
return
}
const payload = JSON.parse(auth.data.toString('utf-8'))
resolve(payload)
} catch (err) {
reject(err)
}
}
})
})
// Return source payload — manager handles addWriter after re-init with shared registry
return {
bootstrapKey: result.bootstrapKey,
writerKey: result.writerKey || null,
instanceKey: result.instanceKey || null,
instanceUrl: result.instanceUrl || null,
callbackToken: result.callbackToken || null
}
}
async destroy () {
if (this._timeout) {
clearTimeout(this._timeout)
this._timeout = null
}
if (this._pairing) {
await this._pairing.close()
this._pairing = null
}
// Destroy ephemeral swarm only if we created it
if (this._swarm && !this._externalSwarm) {
await this._swarm.destroy()
}
this._swarm = null
}
}