OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-rooms / lib / room-pairing.js

220 lines · javascript ref:
// P2P invite-based join + write-enrollment for a Room, over Hyperswarm via
// blind-pairing — the canonical Keet pattern (cf. autopass, and our own
// pear-git-v3 device-linker). Replaces the bearer invite (invitations.js) +
// out-of-band (HTTP) enroll: the invite code is the WHOLE UX, and the
// writer-grant happens over the DHT DURING pairing — no HTTP, no
// --external-url, NAT-traversal for free.
//
// Host (owns the room; an existing writer):
//   const p = createRoomPairing(room, { role, swarm })
//   On candidate pair, reads the candidate's localKeyHex (sent as pairing
//   userData) and, for contributor+, calls room.addWriter(localKeyHex, role)
//   over the pairing channel. The invite string also carries the room's
//   bootstrapKey so the candidate can mount + derive its localKeyHex first.
//   `p.invite` is the code to share; call `p.close()` when done.
//
// Candidate (joining):
//   const { room, role } = await joinRoomPairing(invite, {
//     corestore, swarm, schema, namespace })
//   Mounts the room (read-only) to derive localKeyHex, presents it over
//   pairing, then becomes writable once the host's addWriter replicates
//   (contributor+); stays a read-only mirror for `reader`.
//
// Swarm is INJECTED — hyper-rooms stays swarm-agnostic (the daemon owns the
// one shared Hyperswarm; blind-pairing + corestore replication attach to it).

import BlindPairing from 'blind-pairing'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'

import { Room } from './room.js'
import { ROLES } from './invitations.js'

const PREFIX = 'roompair1:'
const PAIR_TIMEOUT_MS = 5 * 60 * 1000

// Invite role → Autobase writer role: 'reader' replicates read-only (mirror);
// contributor/maintainer/admin become actual writers. Mirrors hyper-orgs'
// _roomRole so the two stay consistent.
function roomWriterRole (inviteRole) {
  return inviteRole === 'reader' ? 'mirror' : 'writer'
}

function encodeInvite (obj) {
  return PREFIX + b4a.toString(b4a.from(JSON.stringify(obj), 'utf-8'), 'base64')
}

function decodeInvite (str) {
  if (typeof str !== 'string' || !str.startsWith(PREFIX)) {
    throw new Error('not a room-pairing invite')
  }
  const obj = JSON.parse(b4a.toString(b4a.from(str.slice(PREFIX.length), 'base64'), 'utf-8'))
  if (obj.v !== 1 || !obj.invite || !obj.bootstrapKey) throw new Error('malformed room-pairing invite')
  return obj
}

/**
 * Decode a room-pairing invite WITHOUT joining — exposes { bootstrapKey,
 * role, meta } so a domain wrapper (hyper-orgs / hyper-issues) can read its
 * advisory `meta` (e.g. the org/repo name) and derive its own namespace
 * before calling joinRoomPairing.
 */
export function decodeRoomPairingInvite (str) {
  const o = decodeInvite(str)
  return { bootstrapKey: o.bootstrapKey, role: o.role || 'reader', meta: o.meta || null }
}

// Register a blind-pairing member that, on candidate pair, addWriters the
// candidate (contributor+) and confirms. Shared by createRoomPairing (fresh
// invite) and resumeRoomPairing (re-register a persisted invite on boot).
function registerPairingMember ({ room, role, swarm, pairingPublicKey, invitePublicKey, inviteKp, discoveryKey }) {
  const bp = new BlindPairing(swarm)
  const member = bp.addMember({
    discoveryKey,
    onadd: async (request) => {
      // The candidate's userData is its Room localKeyHex (the writer key it
      // appends from). open() returns it after invite verification.
      const userData = request.open(invitePublicKey)
      const writerKeyHex = userData ? b4a.toString(userData, 'utf-8') : null
      try {
        if (role !== 'reader' && writerKeyHex && writerKeyHex.length === 64) {
          await room.addWriter('paired-' + writerKeyHex.slice(0, 12), {
            writerKey: writerKeyHex,
            role: roomWriterRole(role)
          })
        }
      } catch (err) {
        // eslint-disable-next-line no-console
        console.error('[room-pairing] addWriter failed:', err && err.message)
      }
      const ack = b4a.from(JSON.stringify({ ok: true, role }), 'utf-8')
      request.confirm({
        key: pairingPublicKey,
        encryptionKey: null,
        additional: { data: ack, signature: crypto.sign(ack, inviteKp.secretKey) }
      })
    }
  })
  return {
    async close () {
      // Drain so the confirm ack reaches the candidate before teardown
      // (same flush-before-close race device-linker.js documents).
      try {
        const conns = swarm.connections
        await Promise.all([...conns].map((c) => (typeof c.flush === 'function' ? c.flush() : null)).filter(Boolean)).catch(() => {})
      } catch {}
      try { await member.close() } catch {}
      try { await bp.close() } catch {}
    }
  }
}

/**
 * Host side. `room` must be ready and writable (the owner). Returns the
 * shareable `invite`, a persistable `descriptor` (to re-register after a
 * restart), and a `close()`.
 * @param {Room} room
 * @param {{ role?: string, swarm: object, meta?: object }} opts
 * @returns {{ invite: string, descriptor: object, discoveryKey: Buffer, close: () => Promise<void> }}
 */
export function createRoomPairing (room, opts = {}) {
  const role = opts.role || 'reader'
  const swarm = opts.swarm
  if (!swarm) throw new Error('createRoomPairing requires { swarm }')
  if (!ROLES.includes(role)) throw new Error('unknown role: ' + role)
  if (!room || !room.keyHex) throw new Error('createRoomPairing requires a ready room')

  const pairingKey = crypto.keyPair()
  const { invite, publicKey, discoveryKey, seed } = BlindPairing.createInvite(pairingKey.publicKey)
  const inviteKp = crypto.keyPair(seed)

  const { close } = registerPairingMember({
    room, role, swarm, pairingPublicKey: pairingKey.publicKey, invitePublicKey: publicKey, inviteKp, discoveryKey
  })

  const invitation = encodeInvite({ v: 1, invite: b4a.toString(invite, 'hex'), bootstrapKey: room.keyHex, role, meta: opts.meta || null })
  // Persistable descriptor — re-register this exact pairing member after a
  // restart (via resumeRoomPairing) so an already-shared invite keeps working.
  // Only `seed` is sensitive (the invite secret, owner-held); no writer or
  // identity secret is stored.
  const descriptor = {
    role,
    meta: opts.meta || null,
    bootstrapKey: room.keyHex,
    invitation,
    discoveryKey: b4a.toString(discoveryKey, 'hex'),
    invitePublicKey: b4a.toString(publicKey, 'hex'),
    pairingPublicKey: b4a.toString(pairingKey.publicKey, 'hex'),
    seed: b4a.toString(seed, 'hex')
  }
  return { invite: invitation, descriptor, discoveryKey, close }
}

/**
 * Re-register a pairing member from a persisted descriptor (after a daemon
 * restart) so an already-shared invite keeps pairing. `room` must be the same
 * room the descriptor was minted for (matching bootstrapKey).
 * @returns {{ close: () => Promise<void> }}
 */
export function resumeRoomPairing (room, descriptor, swarm) {
  if (!swarm) throw new Error('resumeRoomPairing requires a swarm')
  if (!descriptor || !descriptor.discoveryKey || !descriptor.seed) {
    throw new Error('resumeRoomPairing requires a descriptor with { discoveryKey, seed, ... }')
  }
  if (!room || !room.keyHex) throw new Error('resumeRoomPairing requires a ready room')
  if (descriptor.bootstrapKey && room.keyHex !== descriptor.bootstrapKey) {
    throw new Error('resumeRoomPairing: room does not match descriptor bootstrapKey')
  }
  return registerPairingMember({
    room,
    role: descriptor.role || 'reader',
    swarm,
    pairingPublicKey: b4a.from(descriptor.pairingPublicKey, 'hex'),
    invitePublicKey: b4a.from(descriptor.invitePublicKey, 'hex'),
    inviteKp: crypto.keyPair(b4a.from(descriptor.seed, 'hex')),
    discoveryKey: b4a.from(descriptor.discoveryKey, 'hex')
  })
}

/**
 * Candidate side. Mounts the room from the invite's bootstrapKey, presents
 * its localKeyHex over pairing, returns the mounted Room. For contributor+
 * the room becomes writable once the host's addWriter op replicates; for
 * 'reader' it stays a read-only mirror. Caller owns closing the returned room.
 * @param {string} inviteStr
 * @param {{ corestore: object, swarm: object, schema: object, namespace?: string }} opts
 * @returns {Promise<{ room: Room, role: string, localKeyHex: string }>}
 */
export async function joinRoomPairing (inviteStr, opts = {}) {
  const { corestore, swarm, schema } = opts
  if (!corestore) throw new Error('joinRoomPairing requires { corestore }')
  if (!swarm) throw new Error('joinRoomPairing requires { swarm }')
  if (!schema) throw new Error('joinRoomPairing requires { schema }')
  const decoded = decodeInvite(inviteStr)
  const bootstrapKey = decoded.bootstrapKey

  const room = new Room(corestore, {
    schema,
    namespace: opts.namespace || ('room-join/' + bootstrapKey.slice(0, 16)),
    bootstrapKey
  })
  await room.ready()
  const localKeyHex = room.localKeyHex

  const bp = new BlindPairing(swarm)
  try {
    await new Promise((resolve, reject) => {
      const timer = setTimeout(() => reject(new Error('room-pairing timeout')), PAIR_TIMEOUT_MS)
      bp.addCandidate({
        invite: b4a.from(decoded.invite, 'hex'),
        userData: b4a.from(localKeyHex, 'utf-8'),
        onadd: async () => { clearTimeout(timer); resolve() }
      })
    })
  } finally {
    try { await bp.close() } catch {}
  }

  return { room, role: decoded.role, meta: decoded.meta || null, localKeyHex }
}