// favela-web pairing — the hpp-initiated blind-pairing sub-key delegation, over a LOCAL testnet DHT.
// Empirically proves the round-trip: the candidate (favela-web) sends the browser sub-key UP through the
// invite tunnel; the source (hpp) delegates it and the delegation comes back. Run: node test/pairing.test.mjs
import createTestnet from 'hyperdht/testnet.js'
import Hyperswarm from 'hyperswarm'
import { createPairInvite, joinPairInvite } from '../lib/pairing.js'
const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m) }
const SUBKEY = '03' + 'a'.repeat(64) // a 33-byte compressed secp256k1 pubkey (hex)
const DID = 'did:plc:pairtest2222222222222222'
const testnet = await createTestnet(4)
const sourceSwarm = new Hyperswarm({ bootstrap: testnet.bootstrap })
const candidateSwarm = new Hyperswarm({ bootstrap: testnet.bootstrap })
// SOURCE (hpp): mint an invite; on the browser's sub-key, "delegate" it (mock — the transport is the point).
let received = null
const source = await createPairInvite({
swarm: sourceSwarm,
onSubkey: (subKey) => { received = subKey; return { did: DID, subKey, delegation: 'ES256K-sig-abc', exp: 9999999999 } }
})
assert(/^[0-9a-f]+$/.test(source.inviteHex) && source.inviteHex.length > 20, 'source minted an invite (the hash URL)')
// CANDIDATE (favela-web): join with the invite + the sub-key → receive the delegation.
const [delegation] = await Promise.all([
joinPairInvite(source.inviteHex, { subKey: SUBKEY, swarm: candidateSwarm }),
source.waitForPeer()
])
assert(received === SUBKEY, 'the hpp (member) received the browser sub-key UP through the tunnel (userData)')
assert(delegation && delegation.did === DID, 'the candidate received the delegation back (did)')
assert(delegation.subKey === SUBKEY && delegation.delegation === 'ES256K-sig-abc', 'delegation carries the sub-key + signature')
console.log('\nPAIRING OK: blind-pairing invite → sub-key up → delegation back (hpp-initiated, remote-capable)')
await source.close()
try { await sourceSwarm.destroy(); await candidateSwarm.destroy() } catch {}
await testnet.destroy()
// ── a REFUSAL must reach the candidate, not hang it ──────────────────────────────────────────────────
// If onSubkey throws, the source used to reject its own promise and never confirm — so the joiner sat
// there until its 5-minute timeout with no reason. That is exactly what a phone hit when it pasted a
// BROWSER invite (`favela-web pair`): delegate() rejects a 64-hex keet device key because it wants a
// 66-hex secp256k1 sub-key, and the mismatch surfaced as silence.
console.log('\n# a throwing onSubkey tells the candidate instead of hanging it')
{
const testnet2 = await createTestnet(3)
const mk2 = () => new Hyperswarm({ bootstrap: testnet2.bootstrap })
const sw1 = mk2(); const sw2 = mk2()
const src = await createPairInvite({ swarm: sw1, onSubkey: () => { throw new Error('subKey must be 66 hex') } })
// The source's own promise rejects too — every real caller awaits it (the invite CLIs do), so attach
// the handler here rather than letting node die on an unhandled rejection.
let sourceErr = null
src.waitForPeer().catch((e) => { sourceErr = e.message })
const t0 = Date.now()
const got = await joinPairInvite(src.inviteHex, { subKey: 'aa'.repeat(32), swarm: sw2, timeout: 20000 })
const ms = Date.now() - t0
assert(got && got.error, 'the candidate received an ERROR object: ' + JSON.stringify(got))
assert(/66 hex/.test(got.error), 'carrying the reason: ' + got.error)
assert(ms < 15000, 'and it arrived fast (' + ms + 'ms), not at the timeout')
assert(/66 hex/.test(sourceErr || ''), 'and the SOURCE still learns of it too: ' + sourceErr)
try { await src.close() } catch {}
await sw1.destroy(); await sw2.destroy(); await testnet2.destroy()
}