OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-rooms commits / 3c7245f

Add RoomPairing — blind-pairing join + write-enrollment over the swarm (P2P, no HTTP)
The canonical Keet invite-code flow (cf. autopass + our pear-git-v3 device-linker), as the P2P replacement for the bearer invite + the out-of-band enroll that rode HTTP: - createRoomPairing(room, { role, swarm }) — host announces a blind-pairing invite; on candidate pair it reads the candidate's localKeyHex (sent as pairing userData) and, for contributor+, calls room.addWriter over the pairing channel. The invite carries the room bootstrapKey so the candidate mounts + derives its localKeyHex first. reader -> read-only mirror. - joinRoomPairing(invite, { corestore, swarm, schema, namespace }) — mounts the room, presents localKeyHex, becomes writable once the host's addWriter replicates. Invite code is the whole UX; writer-grant happens over the DHT. Swarm is INJECTED — hyper-rooms stays swarm-agnostic (the daemon owns the one shared Hyperswarm). Room needs no keyPair pinning: the writer key is the corestore-derived localKeyHex. Proof: test/room-pairing.test.js (real testnet + Hyperswarm) — contributor pairs -> writer -> bidirectional writes (no HTTP); reader stays a read-only mirror. 9/9. Regression: room.test 13/13, compose-schemas 3/3. blind-pairing added as a dependency; hyperswarm + @hyperswarm/testnet as devDependencies (test-only — the feature takes the swarm injected). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
3c7245f95b09e240630fb0b65abc1681fbf950b0
by plankatron <fleeky@gmail.com> · 2026-05-27T18:32:06.000Z

Changed files (4) +280 -1

index.js +3 -0
……@@ -7,9 +7,12 @@
77 * composeSchemas — merge several schemas onto one Room (linked-core composites)
88 * invites — bearer-invite encode/decode (shared by hyper-orgs
99 * `org invite` + hyper-issues `repo invite`)
10 * pairing — blind-pairing join + write-enrollment over Hyperswarm
11 * (the canonical Keet invite-code flow; P2P, no HTTP)
1012 */
1113
1214export { Room } from './lib/room.js'
1315export { jsonSchema } from './lib/json-schema.js'
1416export { composeSchemas } from './lib/compose-schemas.js'
1517export { encodeInvite, decodeInvite, newSeed, isExpired, ROLES } from './lib/invitations.js'
18export { createRoomPairing, joinRoomPairing } from './lib/room-pairing.js'
lib/room-pairing.js +158 -0
……@@ -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
26import BlindPairing from 'blind-pairing'
27import crypto from 'hypercore-crypto'
28import b4a from 'b4a'
29
30import { Room } from './room.js'
31import { ROLES } from './invitations.js'
32
33const PREFIX = 'roompair1:'
34const 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.
39function roomWriterRole (inviteRole) {
40 return inviteRole === 'reader' ? 'mirror' : 'writer'
41}
42
43function encodeInvite (obj) {
44 return PREFIX + b4a.toString(b4a.from(JSON.stringify(obj), 'utf-8'), 'base64')
45}
46
47function 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 */
63export 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 */
127export 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}
package.json +4 -1
……@@ -10,13 +10,16 @@
1010 "dependencies": {
1111 "autobase": "^7.20.0",
1212 "b4a": "^1.6.0",
13 "blind-pairing": "^2.3.1",
1314 "hyperbee": "^2.20.0",
1415 "hypercore-crypto": "^3.4.0",
1516 "ready-resource": "^1.1.0"
1617 },
1718 "devDependencies": {
19 "@hyperswarm/testnet": "^3.1.4",
1820 "autobase-test-helpers": "^2.0.0",
1921 "brittle": "^3.0.0",
20 "corestore": "^7.0.0"
22 "corestore": "^7.0.0",
23 "hyperswarm": "^4.8.0"
2124 }
2225}
test/room-pairing.test.js +115 -0
……@@ -0,0 +1,115 @@
1// Proof for RoomPairing (blind-pairing join + write-enrollment over Hyperswarm).
2//
3// Canonical Keet pattern: an invite code → blind-pairing over the DHT → the
4// host addWriters the candidate DURING pairing. No HTTP, no --external-url.
5// This is the P2P replacement for the bearer-invite (invitations.js) + the
6// out-of-band enroll that previously rode HTTP.
7
8import test from 'brittle'
9import Corestore from 'corestore'
10import Hyperswarm from 'hyperswarm'
11import createTestnet from '@hyperswarm/testnet'
12import { mkdtempSync } from 'fs'
13import { join } from 'path'
14import { tmpdir } from 'os'
15
16import { Room, jsonSchema } from '../index.js'
17import { createRoomPairing, joinRoomPairing } from '../lib/room-pairing.js'
18
19let n = 0
20const tmp = () => mkdtempSync(join(tmpdir(), 'rp-' + (n++) + '-'))
21
22const schema = jsonSchema({
23 async apply (batch, op) {
24 if (op.type === 'put') await batch.put('k:' + op.id, { id: op.id, by: op.by })
25 },
26 validate (op) {
27 if (op.type === 'put' && typeof op.id === 'undefined') throw new Error('put requires id')
28 }
29})
30
31async function waitFor (pred, ms = 25000) {
32 const t0 = Date.now()
33 while (Date.now() - t0 < ms) {
34 try { if (await pred()) return true } catch {}
35 await new Promise((r) => setTimeout(r, 100))
36 }
37 return false
38}
39
40async function makeNode (bootstrap) {
41 const store = new Corestore(tmp())
42 const swarm = new Hyperswarm({ bootstrap })
43 swarm.on('connection', (conn) => store.replicate(conn))
44 return { store, swarm }
45}
46
47test('contributor pairs over the swarm and becomes a writer (no HTTP)', async (t) => {
48 const testnet = await createTestnet(3, t.teardown)
49
50 // Host owns the room (creator → writable).
51 const h = await makeNode(testnet.bootstrap)
52 const host = new Room(h.store, { schema, namespace: 'room/test' })
53 await host.ready()
54 t.teardown(async () => { try { await host.close() } catch {}; try { await h.swarm.destroy() } catch {} })
55 t.ok(host.writable, 'host (creator) is writable')
56 await host.append({ type: 'put', id: 1, by: 'host' })
57
58 const pairing = createRoomPairing(host, { role: 'contributor', swarm: h.swarm })
59 t.teardown(async () => { try { await pairing.close() } catch {} })
60 t.ok(typeof pairing.invite === 'string' && pairing.invite.startsWith('roompair1:'), 'invite code minted')
61
62 // Candidate joins via the invite over the DHT.
63 const c = await makeNode(testnet.bootstrap)
64 t.teardown(async () => { try { await c.swarm.destroy() } catch {} })
65 const { room: cand } = await joinRoomPairing(pairing.invite, { corestore: c.store, swarm: c.swarm, schema })
66 t.teardown(async () => { try { await cand.close() } catch {} })
67
68 t.is(cand.keyHex, host.keyHex, 'candidate mounted the SAME room autobase (not a sibling)')
69
70 const becameWritable = await waitFor(() => cand.writable)
71 t.ok(becameWritable, 'candidate became a writer via pairing — over the swarm, no HTTP')
72
73 // Candidate writes; host sees it (bidirectional).
74 const id = Date.now()
75 await cand.append({ type: 'put', id, by: 'cand' })
76 const seen = await waitFor(async () => {
77 await host.base.update()
78 const v = await host.view.get('k:' + id)
79 return !!(v && v.value && v.value.by === 'cand')
80 })
81 t.ok(seen, 'host sees the candidate-authored op (bidirectional writes)')
82})
83
84test('reader pairs over the swarm and stays a read-only mirror', async (t) => {
85 const testnet = await createTestnet(3, t.teardown)
86
87 const h = await makeNode(testnet.bootstrap)
88 const host = new Room(h.store, { schema, namespace: 'room/test' })
89 await host.ready()
90 t.teardown(async () => { try { await host.close() } catch {}; try { await h.swarm.destroy() } catch {} })
91 await host.append({ type: 'put', id: 7, by: 'host' })
92
93 const pairing = createRoomPairing(host, { role: 'reader', swarm: h.swarm })
94 t.teardown(async () => { try { await pairing.close() } catch {} })
95
96 const c = await makeNode(testnet.bootstrap)
97 t.teardown(async () => { try { await c.swarm.destroy() } catch {} })
98 const { room: cand, role } = await joinRoomPairing(pairing.invite, { corestore: c.store, swarm: c.swarm, schema })
99 t.teardown(async () => { try { await cand.close() } catch {} })
100
101 t.is(role, 'reader', 'role echoed as reader')
102 t.is(cand.keyHex, host.keyHex, 'reader mounted the same room autobase')
103
104 // Reader replicates (read) the host's op...
105 const sawRead = await waitFor(async () => {
106 await cand.base.update()
107 const v = await cand.view.get('k:7')
108 return !!(v && v.value && v.value.by === 'host')
109 })
110 t.ok(sawRead, 'reader replicates the room read-only')
111
112 // ...but is NOT a writer.
113 await new Promise((r) => setTimeout(r, 1500))
114 t.absent(cand.writable, 'reader is NOT enrolled as a writer (read-only mirror)')
115})