OverviewFilesCommitsIssuesDocsReleasesPeers

favela-community / lib / discovery.js

104 lines · javascript ref:
// 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