OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-rooms commits / 57d59bd

RoomPairing: persistable descriptor + resumeRoomPairing (survive owner restart)
createRoomPairing now also returns a `descriptor` (discoveryKey, invitePublicKey, pairingPublicKey, seed, role, meta, bootstrapKey) — everything needed to re-register the SAME pairing member after a restart, so an already-shared invite keeps working. resumeRoomPairing(room, descriptor, swarm) rebuilds the member from it (same onadd → addWriter). Member registration extracted into a shared registerPairingMember; only the seed is sensitive (invite secret, owner-held) — no writer/identity secret persisted. Proof: test/room-pairing.test.js +1 — mint invite, capture descriptor, close pairing (simulated restart), resumeRoomPairing, candidate using the ORIGINAL invite still pairs + becomes a writer. 3/3, 12/12 asserts. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
57d59bdbafe85fcef0ed6bd4fc96c1b19f807825
by plankatron <fleeky@gmail.com> · 2026-05-28T01:36:54.000Z

Changed files (3) +105 -25

index.js +1 -1
……@@ -15,4 +15,4 @@ export { Room } from './lib/room.js'
1515export { jsonSchema } from './lib/json-schema.js'
1616export { composeSchemas } from './lib/compose-schemas.js'
1717export { encodeInvite, decodeInvite, newSeed, isExpired, ROLES } from './lib/invitations.js'
18export { createRoomPairing, joinRoomPairing, decodeRoomPairingInvite } from './lib/room-pairing.js'
18export { createRoomPairing, joinRoomPairing, resumeRoomPairing, decodeRoomPairingInvite } from './lib/room-pairing.js'
lib/room-pairing.js +73 -23
……@@ -64,31 +64,17 @@ export function decodeRoomPairingInvite (str) {
6464 return { bootstrapKey: o.bootstrapKey, role: o.role || 'reader', meta: o.meta || null }
6565}
6666
67/**
68 * Host side. `room` must be ready and writable (the owner). Returns a
69 * shareable `invite` string + a `close()` to tear down the pairing member.
70 * @param {Room} room
71 * @param {{ role?: string, swarm: object }} opts
72 * @returns {{ invite: string, discoveryKey: Buffer, close: () => Promise<void> }}
73 */
74export function createRoomPairing (room, opts = {}) {
75 const role = opts.role || 'reader'
76 const swarm = opts.swarm
77 if (!swarm) throw new Error('createRoomPairing requires { swarm }')
78 if (!ROLES.includes(role)) throw new Error('unknown role: ' + role)
79 if (!room || !room.keyHex) throw new Error('createRoomPairing requires a ready room')
80
81 const pairingKey = crypto.keyPair()
82 const { invite, publicKey, discoveryKey, seed } = BlindPairing.createInvite(pairingKey.publicKey)
83 const inviteKp = crypto.keyPair(seed)
84
67// Register a blind-pairing member that, on candidate pair, addWriters the
68// candidate (contributor+) and confirms. Shared by createRoomPairing (fresh
69// invite) and resumeRoomPairing (re-register a persisted invite on boot).
70function registerPairingMember ({ room, role, swarm, pairingPublicKey, invitePublicKey, inviteKp, discoveryKey }) {
8571 const bp = new BlindPairing(swarm)
8672 const member = bp.addMember({
8773 discoveryKey,
8874 onadd: async (request) => {
8975 // The candidate's userData is its Room localKeyHex (the writer key it
9076 // appends from). open() returns it after invite verification.
91 const userData = request.open(publicKey)
77 const userData = request.open(invitePublicKey)
9278 const writerKeyHex = userData ? b4a.toString(userData, 'utf-8') : null
9379 try {
9480 if (role !== 'reader' && writerKeyHex && writerKeyHex.length === 64) {
……@@ -103,16 +89,13 @@ export function createRoomPairing (room, opts = {}) {
10389 }
10490 const ack = b4a.from(JSON.stringify({ ok: true, role }), 'utf-8')
10591 request.confirm({
106 key: pairingKey.publicKey,
92 key: pairingPublicKey,
10793 encryptionKey: null,
10894 additional: { data: ack, signature: crypto.sign(ack, inviteKp.secretKey) }
10995 })
11096 }
11197 })
112
11398 return {
114 invite: encodeInvite({ v: 1, invite: b4a.toString(invite, 'hex'), bootstrapKey: room.keyHex, role, meta: opts.meta || null }),
115 discoveryKey,
11699 async close () {
117100 // Drain so the confirm ack reaches the candidate before teardown
118101 // (same flush-before-close race device-linker.js documents).
……@@ -126,6 +109,73 @@ export function createRoomPairing (room, opts = {}) {
126109 }
127110}
128111
112/**
113 * Host side. `room` must be ready and writable (the owner). Returns the
114 * shareable `invite`, a persistable `descriptor` (to re-register after a
115 * restart), and a `close()`.
116 * @param {Room} room
117 * @param {{ role?: string, swarm: object, meta?: object }} opts
118 * @returns {{ invite: string, descriptor: object, discoveryKey: Buffer, close: () => Promise<void> }}
119 */
120export function createRoomPairing (room, opts = {}) {
121 const role = opts.role || 'reader'
122 const swarm = opts.swarm
123 if (!swarm) throw new Error('createRoomPairing requires { swarm }')
124 if (!ROLES.includes(role)) throw new Error('unknown role: ' + role)
125 if (!room || !room.keyHex) throw new Error('createRoomPairing requires a ready room')
126
127 const pairingKey = crypto.keyPair()
128 const { invite, publicKey, discoveryKey, seed } = BlindPairing.createInvite(pairingKey.publicKey)
129 const inviteKp = crypto.keyPair(seed)
130
131 const { close } = registerPairingMember({
132 room, role, swarm, pairingPublicKey: pairingKey.publicKey, invitePublicKey: publicKey, inviteKp, discoveryKey
133 })
134
135 const invitation = encodeInvite({ v: 1, invite: b4a.toString(invite, 'hex'), bootstrapKey: room.keyHex, role, meta: opts.meta || null })
136 // Persistable descriptor — re-register this exact pairing member after a
137 // restart (via resumeRoomPairing) so an already-shared invite keeps working.
138 // Only `seed` is sensitive (the invite secret, owner-held); no writer or
139 // identity secret is stored.
140 const descriptor = {
141 role,
142 meta: opts.meta || null,
143 bootstrapKey: room.keyHex,
144 invitation,
145 discoveryKey: b4a.toString(discoveryKey, 'hex'),
146 invitePublicKey: b4a.toString(publicKey, 'hex'),
147 pairingPublicKey: b4a.toString(pairingKey.publicKey, 'hex'),
148 seed: b4a.toString(seed, 'hex')
149 }
150 return { invite: invitation, descriptor, discoveryKey, close }
151}
152
153/**
154 * Re-register a pairing member from a persisted descriptor (after a daemon
155 * restart) so an already-shared invite keeps pairing. `room` must be the same
156 * room the descriptor was minted for (matching bootstrapKey).
157 * @returns {{ close: () => Promise<void> }}
158 */
159export function resumeRoomPairing (room, descriptor, swarm) {
160 if (!swarm) throw new Error('resumeRoomPairing requires a swarm')
161 if (!descriptor || !descriptor.discoveryKey || !descriptor.seed) {
162 throw new Error('resumeRoomPairing requires a descriptor with { discoveryKey, seed, ... }')
163 }
164 if (!room || !room.keyHex) throw new Error('resumeRoomPairing requires a ready room')
165 if (descriptor.bootstrapKey && room.keyHex !== descriptor.bootstrapKey) {
166 throw new Error('resumeRoomPairing: room does not match descriptor bootstrapKey')
167 }
168 return registerPairingMember({
169 room,
170 role: descriptor.role || 'reader',
171 swarm,
172 pairingPublicKey: b4a.from(descriptor.pairingPublicKey, 'hex'),
173 invitePublicKey: b4a.from(descriptor.invitePublicKey, 'hex'),
174 inviteKp: crypto.keyPair(b4a.from(descriptor.seed, 'hex')),
175 discoveryKey: b4a.from(descriptor.discoveryKey, 'hex')
176 })
177}
178
129179/**
130180 * Candidate side. Mounts the room from the invite's bootstrapKey, presents
131181 * its localKeyHex over pairing, returns the mounted Room. For contributor+
test/room-pairing.test.js +31 -1
……@@ -14,7 +14,7 @@ import { join } from 'path'
1414import { tmpdir } from 'os'
1515
1616import { Room, jsonSchema } from '../index.js'
17import { createRoomPairing, joinRoomPairing } from '../lib/room-pairing.js'
17import { createRoomPairing, joinRoomPairing, resumeRoomPairing } from '../lib/room-pairing.js'
1818
1919let n = 0
2020const tmp = () => mkdtempSync(join(tmpdir(), 'rp-' + (n++) + '-'))
……@@ -113,3 +113,33 @@ test('reader pairs over the swarm and stays a read-only mirror', async (t) => {
113113 await new Promise((r) => setTimeout(r, 1500))
114114 t.absent(cand.writable, 'reader is NOT enrolled as a writer (read-only mirror)')
115115})
116
117test('resumeRoomPairing re-registers a persisted invite (survives owner restart)', async (t) => {
118 const testnet = await createTestnet(3, t.teardown)
119 const h = await makeNode(testnet.bootstrap)
120 const host = new Room(h.store, { schema, namespace: 'room/test' })
121 await host.ready()
122 t.teardown(async () => { try { await host.close() } catch {}; try { await h.swarm.destroy() } catch {} })
123 await host.append({ type: 'put', id: 1, by: 'host' })
124
125 // Mint an invite, capture the persistable descriptor, then CLOSE the pairing
126 // (simulating an owner restart — the in-memory member is gone).
127 const pairing = createRoomPairing(host, { role: 'contributor', swarm: h.swarm })
128 const invite = pairing.invite
129 const descriptor = pairing.descriptor
130 t.ok(descriptor && descriptor.discoveryKey && descriptor.seed, 'descriptor is persistable')
131 await pairing.close()
132
133 // Re-register from the persisted descriptor (as boot would after restart).
134 const resumed = resumeRoomPairing(host, descriptor, h.swarm)
135 t.teardown(async () => { try { await resumed.close() } catch {} })
136
137 // A candidate using the ORIGINAL (already-shared) invite still pairs + writes.
138 const c = await makeNode(testnet.bootstrap)
139 t.teardown(async () => { try { await c.swarm.destroy() } catch {} })
140 const { room: cand } = await joinRoomPairing(invite, { corestore: c.store, swarm: c.swarm, schema })
141 t.teardown(async () => { try { await cand.close() } catch {} })
142 t.is(cand.keyHex, host.keyHex, 'candidate mounted the same room')
143 const became = await waitFor(() => cand.writable)
144 t.ok(became, 'candidate became a writer via the RESUMED pairing — persisted invite still works')
145})