| … | … | @@ -0,0 +1,158 @@ |
| 1 | // P2P invite-based join + write-enrollment for a Room, over Hyperswarm via |
| 2 | // blind-pairing — the canonical Keet pattern (cf. autopass, and our own |
| 3 | // pear-git-v3 device-linker). Replaces the bearer invite (invitations.js) + |
| 4 | // out-of-band (HTTP) enroll: the invite code is the WHOLE UX, and the |
| 5 | // writer-grant happens over the DHT DURING pairing — no HTTP, no |
| 6 | // --external-url, NAT-traversal for free. |
| 7 | // |
| 8 | // Host (owns the room; an existing writer): |
| 9 | // const p = createRoomPairing(room, { role, swarm }) |
| 10 | // On candidate pair, reads the candidate's localKeyHex (sent as pairing |
| 11 | // userData) and, for contributor+, calls room.addWriter(localKeyHex, role) |
| 12 | // over the pairing channel. The invite string also carries the room's |
| 13 | // bootstrapKey so the candidate can mount + derive its localKeyHex first. |
| 14 | // `p.invite` is the code to share; call `p.close()` when done. |
| 15 | // |
| 16 | // Candidate (joining): |
| 17 | // const { room, role } = await joinRoomPairing(invite, { |
| 18 | // corestore, swarm, schema, namespace }) |
| 19 | // Mounts the room (read-only) to derive localKeyHex, presents it over |
| 20 | // pairing, then becomes writable once the host's addWriter replicates |
| 21 | // (contributor+); stays a read-only mirror for `reader`. |
| 22 | // |
| 23 | // Swarm is INJECTED — hyper-rooms stays swarm-agnostic (the daemon owns the |
| 24 | // one shared Hyperswarm; blind-pairing + corestore replication attach to it). |
| 25 | |
| 26 | import BlindPairing from 'blind-pairing' |
| 27 | import crypto from 'hypercore-crypto' |
| 28 | import b4a from 'b4a' |
| 29 | |
| 30 | import { Room } from './room.js' |
| 31 | import { ROLES } from './invitations.js' |
| 32 | |
| 33 | const PREFIX = 'roompair1:' |
| 34 | const PAIR_TIMEOUT_MS = 5 * 60 * 1000 |
| 35 | |
| 36 | // Invite role → Autobase writer role: 'reader' replicates read-only (mirror); |
| 37 | // contributor/maintainer/admin become actual writers. Mirrors hyper-orgs' |
| 38 | // _roomRole so the two stay consistent. |
| 39 | function roomWriterRole (inviteRole) { |
| 40 | return inviteRole === 'reader' ? 'mirror' : 'writer' |
| 41 | } |
| 42 | |
| 43 | function encodeInvite (obj) { |
| 44 | return PREFIX + b4a.toString(b4a.from(JSON.stringify(obj), 'utf-8'), 'base64') |
| 45 | } |
| 46 | |
| 47 | function decodeInvite (str) { |
| 48 | if (typeof str !== 'string' || !str.startsWith(PREFIX)) { |
| 49 | throw new Error('not a room-pairing invite') |
| 50 | } |
| 51 | const obj = JSON.parse(b4a.toString(b4a.from(str.slice(PREFIX.length), 'base64'), 'utf-8')) |
| 52 | if (obj.v !== 1 || !obj.invite || !obj.bootstrapKey) throw new Error('malformed room-pairing invite') |
| 53 | return obj |
| 54 | } |
| 55 | |
| 56 | /** |
| 57 | * Host side. `room` must be ready and writable (the owner). Returns a |
| 58 | * shareable `invite` string + a `close()` to tear down the pairing member. |
| 59 | * @param {Room} room |
| 60 | * @param {{ role?: string, swarm: object }} opts |
| 61 | * @returns {{ invite: string, discoveryKey: Buffer, close: () => Promise<void> }} |
| 62 | */ |
| 63 | export function createRoomPairing (room, opts = {}) { |
| 64 | const role = opts.role || 'reader' |
| 65 | const swarm = opts.swarm |
| 66 | if (!swarm) throw new Error('createRoomPairing requires { swarm }') |
| 67 | if (!ROLES.includes(role)) throw new Error('unknown role: ' + role) |
| 68 | if (!room || !room.keyHex) throw new Error('createRoomPairing requires a ready room') |
| 69 | |
| 70 | const pairingKey = crypto.keyPair() |
| 71 | const { invite, publicKey, discoveryKey, seed } = BlindPairing.createInvite(pairingKey.publicKey) |
| 72 | const inviteKp = crypto.keyPair(seed) |
| 73 | |
| 74 | const bp = new BlindPairing(swarm) |
| 75 | const member = bp.addMember({ |
| 76 | discoveryKey, |
| 77 | onadd: async (request) => { |
| 78 | // The candidate's userData is its Room localKeyHex (the writer key it |
| 79 | // appends from). open() returns it after invite verification. |
| 80 | const userData = request.open(publicKey) |
| 81 | const writerKeyHex = userData ? b4a.toString(userData, 'utf-8') : null |
| 82 | try { |
| 83 | if (role !== 'reader' && writerKeyHex && writerKeyHex.length === 64) { |
| 84 | await room.addWriter('paired-' + writerKeyHex.slice(0, 12), { |
| 85 | writerKey: writerKeyHex, |
| 86 | role: roomWriterRole(role) |
| 87 | }) |
| 88 | } |
| 89 | } catch (err) { |
| 90 | // eslint-disable-next-line no-console |
| 91 | console.error('[room-pairing] addWriter failed:', err && err.message) |
| 92 | } |
| 93 | const ack = b4a.from(JSON.stringify({ ok: true, role }), 'utf-8') |
| 94 | request.confirm({ |
| 95 | key: pairingKey.publicKey, |
| 96 | encryptionKey: null, |
| 97 | additional: { data: ack, signature: crypto.sign(ack, inviteKp.secretKey) } |
| 98 | }) |
| 99 | } |
| 100 | }) |
| 101 | |
| 102 | return { |
| 103 | invite: encodeInvite({ v: 1, invite: b4a.toString(invite, 'hex'), bootstrapKey: room.keyHex, role }), |
| 104 | discoveryKey, |
| 105 | async close () { |
| 106 | // Drain so the confirm ack reaches the candidate before teardown |
| 107 | // (same flush-before-close race device-linker.js documents). |
| 108 | try { |
| 109 | const conns = swarm.connections |
| 110 | await Promise.all([...conns].map((c) => (typeof c.flush === 'function' ? c.flush() : null)).filter(Boolean)).catch(() => {}) |
| 111 | } catch {} |
| 112 | try { await member.close() } catch {} |
| 113 | try { await bp.close() } catch {} |
| 114 | } |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | /** |
| 119 | * Candidate side. Mounts the room from the invite's bootstrapKey, presents |
| 120 | * its localKeyHex over pairing, returns the mounted Room. For contributor+ |
| 121 | * the room becomes writable once the host's addWriter op replicates; for |
| 122 | * 'reader' it stays a read-only mirror. Caller owns closing the returned room. |
| 123 | * @param {string} inviteStr |
| 124 | * @param {{ corestore: object, swarm: object, schema: object, namespace?: string }} opts |
| 125 | * @returns {Promise<{ room: Room, role: string, localKeyHex: string }>} |
| 126 | */ |
| 127 | export async function joinRoomPairing (inviteStr, opts = {}) { |
| 128 | const { corestore, swarm, schema } = opts |
| 129 | if (!corestore) throw new Error('joinRoomPairing requires { corestore }') |
| 130 | if (!swarm) throw new Error('joinRoomPairing requires { swarm }') |
| 131 | if (!schema) throw new Error('joinRoomPairing requires { schema }') |
| 132 | const decoded = decodeInvite(inviteStr) |
| 133 | const bootstrapKey = decoded.bootstrapKey |
| 134 | |
| 135 | const room = new Room(corestore, { |
| 136 | schema, |
| 137 | namespace: opts.namespace || ('room-join/' + bootstrapKey.slice(0, 16)), |
| 138 | bootstrapKey |
| 139 | }) |
| 140 | await room.ready() |
| 141 | const localKeyHex = room.localKeyHex |
| 142 | |
| 143 | const bp = new BlindPairing(swarm) |
| 144 | try { |
| 145 | await new Promise((resolve, reject) => { |
| 146 | const timer = setTimeout(() => reject(new Error('room-pairing timeout')), PAIR_TIMEOUT_MS) |
| 147 | bp.addCandidate({ |
| 148 | invite: b4a.from(decoded.invite, 'hex'), |
| 149 | userData: b4a.from(localKeyHex, 'utf-8'), |
| 150 | onadd: async () => { clearTimeout(timer); resolve() } |
| 151 | }) |
| 152 | }) |
| 153 | } finally { |
| 154 | try { await bp.close() } catch {} |
| 155 | } |
| 156 | |
| 157 | return { room, role: decoded.role, localKeyHex } |
| 158 | } |