// 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 }
}