OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / lib / multiwriter / instance-linker.js

221 lines · javascript ref:
/**
 * 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
  }
}