/**
* Relay record — DHT-resolvable relay self-description (iroh adoption Phase 1).
* Covers the codec, resolveRelayRecord over a fake hyperdht, and
* RelayNode.publishRelayRecord wiring (mutablePut on the relay's keyPair).
*/
import test from 'brittle'
import b4a from 'b4a'
import z32 from 'z32'
import {
encodeRelayRecord, decodeRelayRecord, relayRecordHasContent, resolveRelayRecord, MAX_RECORD_BYTES
} from 'p2p-hiverelay/core/relay-node/relay-record.js'
import { RelayNode } from 'p2p-hiverelay/core/relay-node/index.js'
const ROOM = z32.encode(b4a.alloc(32, 7)) // valid 52-char z32
const GW = 'https://relay.example:9100'
test('encode/decode round-trips gatewayUrl + indexRoom', (t) => {
const buf = encodeRelayRecord({ gatewayUrl: GW + '/', indexRoom: ROOM })
t.ok(b4a.isBuffer(buf))
const rec = decodeRelayRecord(buf)
t.is(rec.gatewayUrl, GW, 'trailing slash stripped')
t.is(rec.indexRoom, ROOM)
})
test('encode drops invalid fields (non-http gateway, non-z32 room)', (t) => {
t.is(decodeRelayRecord(encodeRelayRecord({ gatewayUrl: 'ftp://x', indexRoom: 'nope' })).gatewayUrl, null)
t.is(decodeRelayRecord(encodeRelayRecord({ gatewayUrl: 'ftp://x', indexRoom: 'nope' })).indexRoom, null)
const onlyGw = decodeRelayRecord(encodeRelayRecord({ gatewayUrl: GW }))
t.is(onlyGw.gatewayUrl, GW)
t.is(onlyGw.indexRoom, null)
})
test('encode throws over the size cap', (t) => {
t.exception(() => encodeRelayRecord({ gatewayUrl: 'https://' + 'a'.repeat(MAX_RECORD_BYTES) + '.example' }))
})
test('decode returns null on garbage / wrong version / null', (t) => {
t.is(decodeRelayRecord(null), null)
t.is(decodeRelayRecord(b4a.from('not json', 'utf8')), null)
t.is(decodeRelayRecord(b4a.from(JSON.stringify({ v: 99, g: GW }), 'utf8')), null)
})
test('relayRecordHasContent gates empty records', (t) => {
t.ok(relayRecordHasContent({ gatewayUrl: GW }))
t.ok(relayRecordHasContent({ indexRoom: ROOM }))
t.absent(relayRecordHasContent({}))
t.absent(relayRecordHasContent({ gatewayUrl: 'ftp://x', indexRoom: 'nope' }))
})
test('resolveRelayRecord verifies via hyperdht mutableGet + decodes', async (t) => {
const pub = b4a.alloc(32, 3)
const fakeDht = {
async mutableGet (key) {
t.ok(b4a.equals(key, pub), 'resolves by the relay pubkey')
return { value: encodeRelayRecord({ gatewayUrl: GW, indexRoom: ROOM }), seq: 1 }
}
}
const rec = await resolveRelayRecord(fakeDht, pub)
t.is(rec.gatewayUrl, GW)
t.is(rec.indexRoom, ROOM)
// hex pubkey accepted too
t.is((await resolveRelayRecord(fakeDht, b4a.toString(pub, 'hex'))).indexRoom, ROOM)
})
test('resolveRelayRecord returns null on not-found / bad key / no dht', async (t) => {
t.is(await resolveRelayRecord(null, b4a.alloc(32, 1)), null)
t.is(await resolveRelayRecord({ async mutableGet () { return null } }, b4a.alloc(32, 1)), null)
t.is(await resolveRelayRecord({ async mutableGet () { throw new Error('dht down') } }, b4a.alloc(32, 1)), null)
t.is(await resolveRelayRecord({ mutableGet () {} }, b4a.alloc(8, 1)), null, 'rejects non-32-byte key')
})
// ── publishRelayRecord wiring (stub node, no full RelayNode construction) ──
function stubNode ({ gatewayUrl = GW, indexRoom = ROOM, dht = true } = {}) {
const puts = []
const keyPair = { publicKey: b4a.alloc(32, 5), secretKey: b4a.alloc(64, 6) }
return {
puts,
keyPair,
indexRoom,
config: { gatewayUrl },
swarm: dht ? { dht: { async mutablePut (kp, value, opts) { puts.push({ kp, value, opts }) } } } : null,
emit () {}
}
}
test('publishRelayRecord mutablePuts the encoded record on the relay keyPair', async (t) => {
const node = stubNode()
const ok = await RelayNode.prototype.publishRelayRecord.call(node)
t.is(ok, true)
t.is(node.puts.length, 1)
t.is(node.puts[0].kp, node.keyPair, 'signed with the relay identity keyPair')
const decoded = decodeRelayRecord(node.puts[0].value)
t.is(decoded.gatewayUrl, GW)
t.is(decoded.indexRoom, ROOM)
})
test('publishRelayRecord uses a strictly increasing seq so changed values are not SEQ_REUSED', async (t) => {
// Regression: the boot publish is gateway-only; setIndexRoom() republishes a
// CHANGED value once the sidecar's room is ready. Both must NOT collide on
// seq=0, or hyperdht rejects the second put and the indexRoom never propagates.
const node = stubNode({ indexRoom: null }) // 1st publish: gateway-only
t.is(await RelayNode.prototype.publishRelayRecord.call(node), true)
node.indexRoom = ROOM // value changes — indexRoom now present
t.is(await RelayNode.prototype.publishRelayRecord.call(node), true)
t.is(node.puts.length, 2)
t.ok(Number.isInteger(node.puts[0].opts.seq), 'seq is passed as an integer')
t.ok(node.puts[1].opts.seq > node.puts[0].opts.seq, 'seq strictly increases across value changes')
t.is(decodeRelayRecord(node.puts[1].value).indexRoom, ROOM, 'the second record carries the new indexRoom')
})
test('publishRelayRecord no-ops when there is nothing to advertise or no dht', async (t) => {
const empty = stubNode({ gatewayUrl: null, indexRoom: null })
t.is(await RelayNode.prototype.publishRelayRecord.call(empty), false)
t.is(empty.puts.length, 0)
const noDht = stubNode({ dht: false })
t.is(await RelayNode.prototype.publishRelayRecord.call(noDht), false)
})