// discovery.js — DHT name/topic discovery for community rooms.
//
// A community room is identified by its public bootstrapKey (roomKey) — but that's 64 hex chars nobody
// wants to paste. Discovery lets an owner PUBLISH a room under a human NAME (+ optional topics) so peers
// RESOLVE the name to the roomKey, then read the room keylessly (CommunityRooms) as usual.
//
// Mirrors keet-daemon's topic-announce pattern — a mutable DHT metadata record + dht.announce on a
// name/topic key, discovered via dht.lookup → mutableGet — but free of keet-core coupling: it needs only
// a HyperDHT (swarm.dht). Nothing is stored locally; the DHT IS the registry.
//
// announce: mutablePut(recordKeyPair, {roomKey,name,topics}) + announce(labelKey, recordKeyPair) per label
// discover: lookup(labelKey) → peer pubkeys → mutableGet(pk) → {roomKey,…} (dedup, newest ts per room)
//
// Trust model (DHT-registry, same as keet's topic-announce): the record keypair is DERIVED FROM the room's
// PUBLIC roomKey, so every announcer of the same room writes the SAME mutable record (idempotent refresh),
// and only a peer that already knows the roomKey (= the keyless-read capability) can publish/refresh it.
// The name/topic LABELS are open — a squatter could announce their own room under a popular name; the
// roomKey is the real capability, so treat discovery as a hint, not an authorization. Private/invite-only
// communities simply don't announce (DESIGN step 4).
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
const LABEL_PREFIX = 'favela:community:label:' // name/topic → DHT lookup target
const RECORD_PREFIX = 'favela:community:record:' // roomKey → mutable-record keypair seed
const norm = (s) => String(s || '').toLowerCase().trim()
const isRoomKey = (k) => /^[0-9a-f]{64}$/i.test(String(k))
export class CommunityDiscovery {
// dht: a HyperDHT (e.g. `hyperswarm.dht`). No corestore, no storage — records live on the DHT.
constructor (dht) {
if (!dht) throw new Error('CommunityDiscovery requires a HyperDHT (e.g. swarm.dht)')
this._dht = dht
this._announced = new Map() // roomKey -> { keyPair, targets: [Buffer] }
}
_labelKey (s) { return crypto.hash(b4a.from(LABEL_PREFIX + norm(s))) }
_recordKeyPair (roomKey) { return crypto.keyPair(crypto.hash(b4a.from(RECORD_PREFIX + norm(roomKey)))) }
// Publish a room under a NAME (+ optional topics). Idempotent per roomKey (refreshes the same record +
// re-announces). Returns { roomKey, labels } — the normalized labels it announced on.
async announce ({ roomKey, name = '', description = '', topics = [] } = {}) {
if (!isRoomKey(roomKey)) throw new Error('announce requires a 64-hex roomKey')
const nName = norm(name)
const nTopics = (Array.isArray(topics) ? topics : []).map(norm).filter(Boolean)
const labels = [...new Set([nName, ...nTopics].filter(Boolean))]
if (!labels.length) throw new Error('announce requires a name or at least one topic')
const keyPair = this._recordKeyPair(roomKey)
const value = b4a.from(JSON.stringify({
roomKey: norm(roomKey), name: nName, description: String(description || ''), topics: nTopics, ts: Date.now()
}))
await this._dht.mutablePut(keyPair, value, { seq: Math.floor(Date.now() / 1000) })
const targets = []
for (const label of labels) {
const target = this._labelKey(label)
await this._dht.announce(target, keyPair, []).finished()
targets.push(target)
}
this._announced.set(norm(roomKey), { keyPair, targets })
return { ok: true, roomKey: norm(roomKey), labels }
}
// Stop advertising a room (drops the announces; the mutable record ages out). No-op if not announced here.
async unannounce (roomKey) {
const entry = this._announced.get(norm(roomKey))
if (!entry) return { ok: true }
for (const target of entry.targets) { try { await this._dht.unannounce(target, entry.keyPair) } catch {} }
this._announced.delete(norm(roomKey))
return { ok: true }
}
// Discover rooms published under a name or topic. Returns [{ roomKey, name, description, topics, ts }] —
// deduped by roomKey (newest ts wins); a label collision (two rooms same name) yields multiple entries.
async discover (query) {
const target = this._labelKey(query)
const best = new Map() // roomKey -> record
const seenPk = new Set()
for await (const res of this._dht.lookup(target)) {
for (const peer of (res.peers || [])) {
const pkHex = b4a.toString(peer.publicKey, 'hex')
if (seenPk.has(pkHex)) continue
seenPk.add(pkHex)
try {
const rec = await this._dht.mutableGet(peer.publicKey, { latest: true })
if (!rec || !rec.value) continue
const meta = JSON.parse(b4a.toString(rec.value))
if (!isRoomKey(meta.roomKey)) continue
const prev = best.get(meta.roomKey)
if (!prev || (meta.ts || 0) > (prev.ts || 0)) best.set(meta.roomKey, meta)
} catch {}
}
}
return [...best.values()]
}
// Resolve a NAME to a single roomKey (newest match), or null if none found.
async resolve (name) {
const rooms = await this.discover(name)
return rooms.length ? rooms[0].roomKey : null
}
}
export default CommunityDiscovery