| … | … | @@ -3,3 +3,4 @@ |
| 3 | 3 | // keyless read-manager. Shared by favela-web (read), favela-cli / a full-node (write), and hpp. |
| 4 | 4 | export { communitySchema, isDid } from './lib/schema.js' |
| 5 | 5 | export { CommunityRoom, CommunityRooms } from './lib/community-room.js' |
| 6 | export { CommunityDiscovery } from './lib/discovery.js' |
| … | … | @@ -17,6 +17,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'bare-fs' |
| 17 | 17 | import Corestore from 'corestore' |
| 18 | 18 | import Hyperswarm from 'hyperswarm' |
| 19 | 19 | import { CommunityRoom } from './community-room.js' |
| 20 | import { CommunityDiscovery } from './discovery.js' | |
| 20 | 21 | |
| 21 | 22 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) |
| 22 | 23 | const isKey = (k) => /^[0-9a-f]{64}$/i.test(String(k || '')) |
| … | … | @@ -62,7 +63,11 @@ Read (anyone, keyless): |
| 62 | 63 | meta <roomKey> [--sync] community metadata |
| 63 | 64 | |
| 64 | 65 | Live: |
| 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 | |
| 69 | Discovery: | |
| 70 | discover <name-or-topic> find rooms published under a name or topic → roomKeys (keyless) | |
| 66 | 71 | |
| 67 | 72 | Options: |
| 68 | 73 | --storage PATH corestore dir (default ~/.config/favela-community/store) |
| … | … | @@ -210,6 +215,19 @@ export async function run (args, io = {}) { |
| 210 | 215 | return 0 |
| 211 | 216 | } |
| 212 | 217 | |
| 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 | ||
| 213 | 231 | // ── serve (stay live) ── |
| 214 | 232 | if (cmd === 'serve') { |
| 215 | 233 | const key = normKey(positional[1]) |
| … | … | @@ -219,6 +237,16 @@ export async function run (args, io = {}) { |
| 219 | 237 | swarm = await makeSwarm(room) |
| 220 | 238 | const members = await room.members() |
| 221 | 239 | 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 | } | |
| 222 | 250 | let stop |
| 223 | 251 | const done = new Promise((resolve) => { stop = resolve }) |
| 224 | 252 | const onSig = () => stop() |
| … | … | @@ -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). | |
| 20 | import crypto from 'hypercore-crypto' | |
| 21 | import b4a from 'b4a' | |
| 22 | ||
| 23 | const LABEL_PREFIX = 'favela:community:label:' // name/topic → DHT lookup target | |
| 24 | const RECORD_PREFIX = 'favela:community:record:' // roomKey → mutable-record keypair seed | |
| 25 | ||
| 26 | const norm = (s) => String(s || '').toLowerCase().trim() | |
| 27 | const isRoomKey = (k) => /^[0-9a-f]{64}$/i.test(String(k)) | |
| 28 | ||
| 29 | export 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 | ||
| 103 | export default CommunityDiscovery |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-community", |
| 3 | "version": "0.1.1", | |
| 3 | "version": "0.1.2", | |
| 4 | 4 | "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.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -8,7 +8,7 @@ |
| 8 | 8 | "favela-community": "bin/favela-community.js" |
| 9 | 9 | }, |
| 10 | 10 | "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", | |
| 12 | 12 | "e2e": "bare test/live-swarm-e2e.mjs" |
| 13 | 13 | }, |
| 14 | 14 | "dependencies": { |
| … | … | @@ -65,6 +65,9 @@ const bad = await cli(['members', 'not-a-key', '--storage', STORE, '--json']) |
| 65 | 65 | assert(bad.code === 1 && bad.err.some((e) => /64-hex roomKey/.test(e)), 'bad roomKey → exit 1 + message') |
| 66 | 66 | const help = await cli(['help']) |
| 67 | 67 | assert(help.code === 0 && help.out.some((l) => /favela-community/.test(l) && /create/.test(l)), 'help prints usage') |
| 68 | assert(help.out.some((l) => /discover/.test(l)), 'help lists the discover command') | |
| 69 | const discNoArg = await cli(['discover', '--json']) | |
| 70 | assert(discNoArg.code === 1 && discNoArg.err.some((e) => /usage: discover/.test(e)), 'discover with no query → exit 1 + usage (no DHT touched)') | |
| 68 | 71 | const none = await cli([]) |
| 69 | 72 | assert(none.code === 1, 'no command → exit 1 (usage)') |
| 70 | 73 |
| … | … | @@ -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 | |
| 4 | import createTestnet from 'hyperdht/testnet.js' | |
| 5 | import DHT from 'hyperdht' | |
| 6 | import { CommunityDiscovery } from '../index.js' | |
| 7 | ||
| 8 | const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m) } | |
| 9 | const ROOM_A = 'a'.repeat(64) | |
| 10 | const ROOM_B = 'b'.repeat(64) | |
| 11 | ||
| 12 | const testnet = await createTestnet(4) | |
| 13 | const bootstrap = testnet.bootstrap | |
| 14 | const dhtPub = new DHT({ bootstrap }) | |
| 15 | const dhtFind = new DHT({ bootstrap }) | |
| 16 | ||
| 17 | // ── announce room A under a name + topic, room B under the same topic ── | |
| 18 | const pub = new CommunityDiscovery(dhtPub) | |
| 19 | const rA = await pub.announce({ roomKey: ROOM_A, name: 'Cat Lovers', description: 'meow', topics: ['Pets'] }) | |
| 20 | assert(rA.roomKey === ROOM_A && rA.labels.includes('cat lovers') && rA.labels.includes('pets'), 'announce normalizes + returns the room label set') | |
| 21 | await pub.announce({ roomKey: ROOM_B, name: 'Dog Fans', topics: ['pets'] }) | |
| 22 | ||
| 23 | // ── an INDEPENDENT resolver finds it by name and by topic ── | |
| 24 | const find = new CommunityDiscovery(dhtFind) | |
| 25 | const byName = await find.resolve('cat lovers') // case-insensitive | |
| 26 | assert(byName === ROOM_A, 'resolve(name) → roomKey (case-insensitive)') | |
| 27 | ||
| 28 | const petsRooms = await find.discover('pets') | |
| 29 | const petKeys = petsRooms.map((r) => r.roomKey).sort() | |
| 30 | assert(petKeys.includes(ROOM_A) && petKeys.includes(ROOM_B), 'discover(topic) → BOTH rooms announced under that topic') | |
| 31 | const catRec = petsRooms.find((r) => r.roomKey === ROOM_A) | |
| 32 | assert(catRec && catRec.name === 'cat lovers' && catRec.description === 'meow', 'discovered record carries name + description metadata') | |
| 33 | ||
| 34 | assert((await find.resolve('nonexistent community')) === null, 'resolve of an unknown name → null') | |
| 35 | ||
| 36 | // ── unannounce drops the advertisement (best-effort; the record ages out) ── | |
| 37 | await pub.unannounce(ROOM_A) | |
| 38 | assert(true, 'unannounce completes without throwing') | |
| 39 | ||
| 40 | console.log('\nFAVELA-COMMUNITY DISCOVERY OK: announce + resolve-by-name + discover-by-topic over a testnet DHT') | |
| 41 | await dhtPub.destroy(); await dhtFind.destroy(); await testnet.destroy() |