OverviewFilesCommitsIssuesDocsReleasesPeers

favela-community commits / a1ecf0b

favela-community 0.1.2: DHT name/topic discovery (CommunityDiscovery + CLI)
Find a community room by a human NAME instead of pasting its 64-hex roomKey. New CommunityDiscovery (lib/discovery.js) over a HyperDHT — mirrors keet-daemon's topic-announce pattern free of keet-core coupling: announce: mutablePut(recordKeyPair, {roomKey,name,topics}) + dht.announce(labelKey, kp) discover: dht.lookup(labelKey) → mutableGet(pk) → {roomKey,…} (dedup, newest ts) The record keypair is derived from the room's PUBLIC roomKey, so every announcer of a room writes the SAME idempotent record and only a roomKey-holder (= the keyless-read capability) can publish it. Labels are open (DHT-registry trust model, same as keet's); private communities simply don't announce. CLI: `discover <name-or-topic>` (one-shot keyless lookup) and `serve --announce [--name --topic]` (advertise while seeding). Test proves announce + resolve-by-name + discover-by-topic over a local testnet DHT. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
a1ecf0b0230de17427cd5ec05cffeed26663985a
by plankatron <fleeky@gmail.com> · 2026-07-15T13:03:23.000Z

Changed files (6) +179 -3

index.js +1 -0
……@@ -3,3 +3,4 @@
33// keyless read-manager. Shared by favela-web (read), favela-cli / a full-node (write), and hpp.
44export { communitySchema, isDid } from './lib/schema.js'
55export { CommunityRoom, CommunityRooms } from './lib/community-room.js'
6export { CommunityDiscovery } from './lib/discovery.js'
lib/cli.js +29 -1
……@@ -17,6 +17,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'bare-fs'
1717import Corestore from 'corestore'
1818import Hyperswarm from 'hyperswarm'
1919import { CommunityRoom } from './community-room.js'
20import { CommunityDiscovery } from './discovery.js'
2021
2122const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
2223const isKey = (k) => /^[0-9a-f]{64}$/i.test(String(k || ''))
……@@ -62,7 +63,11 @@ Read (anyone, keyless):
6263 meta <roomKey> [--sync] community metadata
6364
6465Live:
65 serve <roomKey> keep the room open + swarm-joined (seed + stay writable)
66 serve <roomKey> [--announce] [--name NAME] [--topic a,b]
67 keep the room open + swarm-joined (seed + stay writable);
68 --announce advertises it for discovery by name/topic while up
69Discovery:
70 discover <name-or-topic> find rooms published under a name or topic → roomKeys (keyless)
6671
6772Options:
6873 --storage PATH corestore dir (default ~/.config/favela-community/store)
……@@ -210,6 +215,19 @@ export async function run (args, io = {}) {
210215 return 0
211216 }
212217
218 // ── discover (keyless, one-shot DHT lookup) ──
219 if (cmd === 'discover') {
220 const query = positional[1]
221 if (!query) { err('discover: usage: discover <name-or-topic>'); return 1 }
222 swarm = new Hyperswarm()
223 if (swarm.dht && swarm.dht.ready) await swarm.dht.ready()
224 const rooms = await new CommunityDiscovery(swarm.dht).discover(query)
225 emit({ query, rooms }, rooms.length
226 ? rooms.map((r) => `${r.roomKey} ${r.name || '(unnamed)'}${(r.topics && r.topics.length) ? ' #' + r.topics.join(' #') : ''}`).join('\n')
227 : '(no rooms found)')
228 return 0
229 }
230
213231 // ── serve (stay live) ──
214232 if (cmd === 'serve') {
215233 const key = normKey(positional[1])
……@@ -219,6 +237,16 @@ export async function run (args, io = {}) {
219237 swarm = await makeSwarm(room)
220238 const members = await room.members()
221239 out(`serving ${key}\n writable: ${room.writable} · members: ${members.length}\n discovery: ${room.discoveryKey ? Buffer.from(room.discoveryKey).toString('hex').slice(0, 16) + '…' : '?'}\n (Ctrl-C to stop)`)
240 // Optional: advertise the room for discovery while we're serving (announce stays live as long as this
241 // process is up — the DHT announce record refreshes from this node). Name defaults to the room's meta.
242 if (flags.announce || flags.name || flags.topic) {
243 const name = flags.name ? String(flags.name) : ((await room.meta()).name || '')
244 const topics = flags.topic ? String(flags.topic).split(',').map((s) => s.trim()).filter(Boolean) : []
245 try {
246 const r = await new CommunityDiscovery(swarm.dht).announce({ roomKey: key, name, topics })
247 out(` announced for discovery as: ${r.labels.join(', ') || '(none)'}`)
248 } catch (e) { err(` announce failed: ${e.message}`) }
249 }
222250 let stop
223251 const done = new Promise((resolve) => { stop = resolve })
224252 const onSig = () => stop()
lib/discovery.js +103 -0
……@@ -0,0 +1,103 @@
1// discovery.js — DHT name/topic discovery for community rooms.
2//
3// A community room is identified by its public bootstrapKey (roomKey) — but that's 64 hex chars nobody
4// wants to paste. Discovery lets an owner PUBLISH a room under a human NAME (+ optional topics) so peers
5// RESOLVE the name to the roomKey, then read the room keylessly (CommunityRooms) as usual.
6//
7// Mirrors keet-daemon's topic-announce pattern — a mutable DHT metadata record + dht.announce on a
8// name/topic key, discovered via dht.lookup → mutableGet — but free of keet-core coupling: it needs only
9// a HyperDHT (swarm.dht). Nothing is stored locally; the DHT IS the registry.
10//
11// announce: mutablePut(recordKeyPair, {roomKey,name,topics}) + announce(labelKey, recordKeyPair) per label
12// discover: lookup(labelKey) → peer pubkeys → mutableGet(pk) → {roomKey,…} (dedup, newest ts per room)
13//
14// Trust model (DHT-registry, same as keet's topic-announce): the record keypair is DERIVED FROM the room's
15// PUBLIC roomKey, so every announcer of the same room writes the SAME mutable record (idempotent refresh),
16// and only a peer that already knows the roomKey (= the keyless-read capability) can publish/refresh it.
17// The name/topic LABELS are open — a squatter could announce their own room under a popular name; the
18// roomKey is the real capability, so treat discovery as a hint, not an authorization. Private/invite-only
19// communities simply don't announce (DESIGN step 4).
20import crypto from 'hypercore-crypto'
21import b4a from 'b4a'
22
23const LABEL_PREFIX = 'favela:community:label:' // name/topic → DHT lookup target
24const RECORD_PREFIX = 'favela:community:record:' // roomKey → mutable-record keypair seed
25
26const norm = (s) => String(s || '').toLowerCase().trim()
27const isRoomKey = (k) => /^[0-9a-f]{64}$/i.test(String(k))
28
29export class CommunityDiscovery {
30 // dht: a HyperDHT (e.g. `hyperswarm.dht`). No corestore, no storage — records live on the DHT.
31 constructor (dht) {
32 if (!dht) throw new Error('CommunityDiscovery requires a HyperDHT (e.g. swarm.dht)')
33 this._dht = dht
34 this._announced = new Map() // roomKey -> { keyPair, targets: [Buffer] }
35 }
36
37 _labelKey (s) { return crypto.hash(b4a.from(LABEL_PREFIX + norm(s))) }
38 _recordKeyPair (roomKey) { return crypto.keyPair(crypto.hash(b4a.from(RECORD_PREFIX + norm(roomKey)))) }
39
40 // Publish a room under a NAME (+ optional topics). Idempotent per roomKey (refreshes the same record +
41 // re-announces). Returns { roomKey, labels } — the normalized labels it announced on.
42 async announce ({ roomKey, name = '', description = '', topics = [] } = {}) {
43 if (!isRoomKey(roomKey)) throw new Error('announce requires a 64-hex roomKey')
44 const nName = norm(name)
45 const nTopics = (Array.isArray(topics) ? topics : []).map(norm).filter(Boolean)
46 const labels = [...new Set([nName, ...nTopics].filter(Boolean))]
47 if (!labels.length) throw new Error('announce requires a name or at least one topic')
48 const keyPair = this._recordKeyPair(roomKey)
49 const value = b4a.from(JSON.stringify({
50 roomKey: norm(roomKey), name: nName, description: String(description || ''), topics: nTopics, ts: Date.now()
51 }))
52 await this._dht.mutablePut(keyPair, value, { seq: Math.floor(Date.now() / 1000) })
53 const targets = []
54 for (const label of labels) {
55 const target = this._labelKey(label)
56 await this._dht.announce(target, keyPair, []).finished()
57 targets.push(target)
58 }
59 this._announced.set(norm(roomKey), { keyPair, targets })
60 return { ok: true, roomKey: norm(roomKey), labels }
61 }
62
63 // Stop advertising a room (drops the announces; the mutable record ages out). No-op if not announced here.
64 async unannounce (roomKey) {
65 const entry = this._announced.get(norm(roomKey))
66 if (!entry) return { ok: true }
67 for (const target of entry.targets) { try { await this._dht.unannounce(target, entry.keyPair) } catch {} }
68 this._announced.delete(norm(roomKey))
69 return { ok: true }
70 }
71
72 // Discover rooms published under a name or topic. Returns [{ roomKey, name, description, topics, ts }] —
73 // deduped by roomKey (newest ts wins); a label collision (two rooms same name) yields multiple entries.
74 async discover (query) {
75 const target = this._labelKey(query)
76 const best = new Map() // roomKey -> record
77 const seenPk = new Set()
78 for await (const res of this._dht.lookup(target)) {
79 for (const peer of (res.peers || [])) {
80 const pkHex = b4a.toString(peer.publicKey, 'hex')
81 if (seenPk.has(pkHex)) continue
82 seenPk.add(pkHex)
83 try {
84 const rec = await this._dht.mutableGet(peer.publicKey, { latest: true })
85 if (!rec || !rec.value) continue
86 const meta = JSON.parse(b4a.toString(rec.value))
87 if (!isRoomKey(meta.roomKey)) continue
88 const prev = best.get(meta.roomKey)
89 if (!prev || (meta.ts || 0) > (prev.ts || 0)) best.set(meta.roomKey, meta)
90 } catch {}
91 }
92 }
93 return [...best.values()]
94 }
95
96 // Resolve a NAME to a single roomKey (newest match), or null if none found.
97 async resolve (name) {
98 const rooms = await this.discover(name)
99 return rooms.length ? rooms[0].roomKey : null
100 }
101}
102
103export default CommunityDiscovery
package.json +2 -2
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-community",
3 "version": "0.1.1",
3 "version": "0.1.2",
44 "description": "Co-owned, plaintext community rooms for Favela — membership + moderation on a hyper-rooms Autobase. Read KEYLESSLY by the gateway (favela-web); WRITTEN by mods (hpp / full-node / CLI). One community protocol, shared across surfaces.",
55 "type": "module",
66 "main": "index.js",
……@@ -8,7 +8,7 @@
88 "favela-community": "bin/favela-community.js"
99 },
1010 "scripts": {
11 "test": "bare test/community-room.test.mjs && bare test/cli.test.mjs",
11 "test": "bare test/community-room.test.mjs && bare test/cli.test.mjs && bare test/discovery.test.mjs",
1212 "e2e": "bare test/live-swarm-e2e.mjs"
1313 },
1414 "dependencies": {
test/cli.test.mjs +3 -0
……@@ -65,6 +65,9 @@ const bad = await cli(['members', 'not-a-key', '--storage', STORE, '--json'])
6565assert(bad.code === 1 && bad.err.some((e) => /64-hex roomKey/.test(e)), 'bad roomKey → exit 1 + message')
6666const help = await cli(['help'])
6767assert(help.code === 0 && help.out.some((l) => /favela-community/.test(l) && /create/.test(l)), 'help prints usage')
68assert(help.out.some((l) => /discover/.test(l)), 'help lists the discover command')
69const discNoArg = await cli(['discover', '--json'])
70assert(discNoArg.code === 1 && discNoArg.err.some((e) => /usage: discover/.test(e)), 'discover with no query → exit 1 + usage (no DHT touched)')
6871const none = await cli([])
6972assert(none.code === 1, 'no command → exit 1 (usage)')
7073
test/discovery.test.mjs +41 -0
……@@ -0,0 +1,41 @@
1// favela-community — DHT name/topic discovery. Over a LOCAL testnet DHT (deterministic, no network): an
2// announcer publishes a room under a name + topic; an INDEPENDENT resolver finds the roomKey by name and
3// by topic. Run: bare test/discovery.test.mjs
4import createTestnet from 'hyperdht/testnet.js'
5import DHT from 'hyperdht'
6import { CommunityDiscovery } from '../index.js'
7
8const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m) }
9const ROOM_A = 'a'.repeat(64)
10const ROOM_B = 'b'.repeat(64)
11
12const testnet = await createTestnet(4)
13const bootstrap = testnet.bootstrap
14const dhtPub = new DHT({ bootstrap })
15const dhtFind = new DHT({ bootstrap })
16
17// ── announce room A under a name + topic, room B under the same topic ──
18const pub = new CommunityDiscovery(dhtPub)
19const rA = await pub.announce({ roomKey: ROOM_A, name: 'Cat Lovers', description: 'meow', topics: ['Pets'] })
20assert(rA.roomKey === ROOM_A && rA.labels.includes('cat lovers') && rA.labels.includes('pets'), 'announce normalizes + returns the room label set')
21await pub.announce({ roomKey: ROOM_B, name: 'Dog Fans', topics: ['pets'] })
22
23// ── an INDEPENDENT resolver finds it by name and by topic ──
24const find = new CommunityDiscovery(dhtFind)
25const byName = await find.resolve('cat lovers') // case-insensitive
26assert(byName === ROOM_A, 'resolve(name) → roomKey (case-insensitive)')
27
28const petsRooms = await find.discover('pets')
29const petKeys = petsRooms.map((r) => r.roomKey).sort()
30assert(petKeys.includes(ROOM_A) && petKeys.includes(ROOM_B), 'discover(topic) → BOTH rooms announced under that topic')
31const catRec = petsRooms.find((r) => r.roomKey === ROOM_A)
32assert(catRec && catRec.name === 'cat lovers' && catRec.description === 'meow', 'discovered record carries name + description metadata')
33
34assert((await find.resolve('nonexistent community')) === null, 'resolve of an unknown name → null')
35
36// ── unannounce drops the advertisement (best-effort; the record ages out) ──
37await pub.unannounce(ROOM_A)
38assert(true, 'unannounce completes without throwing')
39
40console.log('\nFAVELA-COMMUNITY DISCOVERY OK: announce + resolve-by-name + discover-by-topic over a testnet DHT')
41await dhtPub.destroy(); await dhtFind.destroy(); await testnet.destroy()