OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-room commits / dfb0eef

pairing: a refusal reaches the joiner instead of hanging it
If onSubkey threw, the source rejected its OWN promise and never confirmed — so the joiner sat on an open tunnel with nothing coming back and waited out its full five-minute timeout, learning nothing about why. That is exactly how a BROWSER invite pasted into a NODE presents. The node sends a 64-hex keet device key, delegate() demands 66-hex secp256k1 and throws, and the node just hangs. Two ceremonies, one string format, and the mismatch was silent. The refusal now travels as an { error } payload — the same shape mintAdoptInvite already returns when a human declines — then rejects locally. Measured: 22ms with the reason attached, instead of 300s of nothing. Best-effort: if the channel is already gone the timeout remains the backstop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dfb0eef4161e740ea3fd3a554f8ebdf43aa63710
by plankatron <fleeky@gmail.com> · 2026-08-09T23:05:19.000Z

Changed files (3) +19 -4

lib/pairing.js +16 -1
……@@ -39,7 +39,22 @@ export async function createPairInvite ({ onSubkey, swarm = null, timeout = TIME
3939 const data = b4a.from(JSON.stringify(delegation))
4040 request.confirm({ key: pairingKey.publicKey, encryptionKey: null, additional: { data, signature: crypto.sign(data, inviteKeyPair.secretKey) } })
4141 resolve({ subKey: hello.subKey, delegation })
42 } catch (e) { reject(e) }
42 } catch (e) {
43 // ⛔ TELL THE CANDIDATE. Rejecting only settles OUR promise; the joiner is left holding an open
44 // tunnel with nothing coming back, so it waits out its full timeout (5 min) and reports
45 // nothing about why. That is exactly how a BROWSER invite pasted into a NODE presents: the
46 // node sends a 64-hex keet device key, delegate() demands 66-hex secp256k1 and throws here,
47 // and the node just… hangs. A refusal is information the other side needs.
48 //
49 // Confirm with an { error } payload — the same shape mintAdoptInvite already returns when a
50 // human declines — then reject locally. Best-effort: if the channel is already gone there is
51 // nothing to say and the timeout remains the backstop.
52 try {
53 const data = b4a.from(JSON.stringify({ error: (e && e.message) || String(e) }))
54 request.confirm({ key: pairingKey.publicKey, encryptionKey: null, additional: { data, signature: crypto.sign(data, inviteKeyPair.secretKey) } })
55 } catch { /* channel gone — the candidate falls back to its timeout */ }
56 reject(e)
57 }
4358 }
4459 })
4560 })
package-lock.json +2 -2
……@@ -1,12 +1,12 @@
11{
22 "name": "atproto-room",
3 "version": "0.2.18",
3 "version": "0.2.19",
44 "lockfileVersion": 3,
55 "requires": true,
66 "packages": {
77 "": {
88 "name": "atproto-room",
9 "version": "0.2.18",
9 "version": "0.2.19",
1010 "hasInstallScript": true,
1111 "dependencies": {
1212 "@ipld/dag-cbor": "^9.2.1",
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "atproto-room",
3 "version": "0.2.18",
3 "version": "0.2.19",
44 "description": "Attestation-gated, encrypted multi-writer room engine — StateBase (keet-model admission) + Lease + signer + RepoBase + LabelBase/LabelRoom + serve-membrane. Extracted from hyper-proto-peer; consumed by the atproto peer (repo devices) and the co-authored labeler.",
55 "type": "module",
66 "main": "index.js",