OverviewFilesCommitsIssuesDocsReleasesPeers

favela-community commits / 8d56b98

feed-registry: durable co-ownable keyless list of feeds + test
The single P2P source of truth that makes "a feed exists" one durable fact every reactor (favela-web index / membrane front+requestCrawl / hpp serve / blind-relay seed) keyless-READs + WATCHes — no per-feed config, no restart. A feed is the primitive; a community is just the roster-curated, co-owned kind. Same three-role shape as CommunityRoom (owner create / mod admitted / reader keyless). - feed-schema.js: add-feed/remove-feed op contract (curator-agnostic, deterministic apply, plaintext by design — feed ids are public) - feed-registry.js: FeedRegistry (owner/mod/reader) + FeedRegistries reactor watcher (baseline + debounced delta) - test: 20 assertions — publish/validate/remove, keyless read, mod admission (co-owned), watcher baseline+delta. Full suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
8d56b98decda17cc9c1ed68444f9b2f198a66755
by plankatron <fleeky@gmail.com> · 2026-07-16T02:00:18.000Z

Changed files (5) +340 -1

index.js +2 -0
……@@ -4,3 +4,5 @@
44export { communitySchema, isDid } from './lib/schema.js'
55export { CommunityRoom, CommunityRooms } from './lib/community-room.js'
66export { CommunityDiscovery } from './lib/discovery.js'
7export { FeedRegistry, FeedRegistries } from './lib/feed-registry.js'
8export { feedRegistrySchema, feedKey, FEED_PREFIX } from './lib/feed-schema.js'
lib/feed-registry.js +179 -0
……@@ -0,0 +1,179 @@
1// FeedRegistry — the watched, co-ownable list of feeds (favela-web/spec/community-ux.md). One class, three
2// roles (same shape as CommunityRoom):
3// - OWNER (create): genesis writer + sole indexer; publishes/removes feed entries.
4// - MOD (open + admitted): a co-writer, admitted by the owner (addMod) — so a registry can be co-owned.
5// - READER (openReadonly): KEYLESS — opens by the PUBLIC bootstrapKey, holds no key, never a writer;
6// replicates + lists + WATCHES. This is the reactor path (favela-web / membrane / hpp / blind-relay).
7//
8// Entries linearize into a plaintext Hyperbee view (feed-schema.js). It is the source of truth: the mint
9// writes an entry; the reactors watch it and serve/front/index/seed the feed with no per-feed config.
10//
11// hyper-rooms `Room` is imported by SUBPATH (skips the room-pairing/blind-pairing dep the read/write paths
12// here don't use — mod admission is key-exchange, below), matching CommunityRoom.
13import { Room } from 'hyper-rooms/lib/room.js'
14import { feedRegistrySchema, isDid, isRkey, FEED_PREFIX } from './feed-schema.js'
15
16const normKey = (k) => (typeof k === 'string' ? k.toLowerCase() : k)
17
18export class FeedRegistry {
19 constructor (room) { this._room = room }
20
21 // OWNER — create a fresh registry. This peer is the genesis writer AND the sole indexer (N=1, the safe
22 // default per the autobase quorum rule — never auto-grow to N=2). Grow indexers only via addIndexer.
23 static async create (corestore, { namespace = 'feed-registry', name = null } = {}) {
24 const room = new Room(corestore, { schema: feedRegistrySchema, bootstrapKey: null, namespace })
25 await room.ready()
26 const fr = new FeedRegistry(room)
27 if (name) await fr._room.append({ type: 'set-meta', key: 'name', value: name }).catch(() => {})
28 return fr
29 }
30
31 // MOD — open an EXISTING registry to co-write. Writable only AFTER the owner addMods this peer's localKey
32 // (waitWritable blocks until then). Until admitted it can still READ.
33 static async open (corestore, roomKey, { namespace } = {}) {
34 const key = normKey(roomKey)
35 if (!/^[0-9a-f]{64}$/.test(String(key))) throw new Error('roomKey must be a 32-byte hex bootstrap key')
36 const room = new Room(corestore, { schema: feedRegistrySchema, bootstrapKey: key, namespace: namespace || `feed-registry:${key}` })
37 await room.ready()
38 return new FeedRegistry(room)
39 }
40
41 // READER — keyless read-only open (semantically identical to open(); named for intent). The reactors use
42 // this: they never call a write method, so are never admitted and hold no key.
43 static openReadonly (corestore, roomKey, opts) { return FeedRegistry.open(corestore, roomKey, opts) }
44
45 // ── identity / replication surface ──
46 get key () { return this._room.keyHex } // PUBLIC bootstrap key — share to reactors/co-mods
47 get localKey () { return this._room.localKeyHex } // THIS peer's writer key — give to the owner for addMod
48 get discoveryKey () { return this._room.discoveryKey }
49 get writable () { return this._room.writable }
50 get corestore () { return this._room.corestore }
51 get base () { return this._room.base }
52 get view () { return this._room.view }
53
54 async update () { await this._room.base.update() }
55 async close () { await this._room.close() }
56
57 // Block until this peer has been admitted as a writer (autopass whenWritable pattern). Returns false on
58 // timeout. A mod calls this after handing its localKey to the owner, before writing.
59 waitWritable ({ timeout = 30000 } = {}) {
60 const base = this._room.base
61 if (base.writable) return Promise.resolve(true)
62 return new Promise((resolve) => {
63 let done = false
64 const finish = (v) => { if (done) return; done = true; base.off('update', check); clearTimeout(timer); resolve(v) }
65 const check = () => { if (base.writable) finish(true) }
66 const timer = setTimeout(() => finish(false), timeout)
67 base.on('update', check)
68 })
69 }
70
71 // ── reads (keyless-safe) ──
72 // Every registered feed, as the stored entry objects { did, rkey, name, curator, params, roomKey, handle,
73 // createdBy, at }. This is what a reactor iterates: index / front / serve / seed each.
74 async feeds () {
75 await this._room.base.update()
76 const out = []
77 for await (const { value } of this._room.view.createReadStream({ gte: FEED_PREFIX, lt: FEED_PREFIX + '\xff' })) out.push(value)
78 return out
79 }
80
81 async feed (did, rkey) {
82 await this._room.base.update()
83 const node = await this._room.view.get(`${FEED_PREFIX}${did}/${rkey}`)
84 return (node && node.value) || null
85 }
86
87 // ── writes (owner/mods; throw if not admitted) ──
88 // Publish (or update — last-writer-wins on the key) a feed. did = the repo that HOLDS the feed record
89 // (your own hpp for a light feed, or a co-owned community DID). curator/params = the contract favela-web
90 // runs; roomKey = the roster CommunityRoom (only for community/roster feeds). Idempotent per (did, rkey).
91 async addFeed ({ did, rkey, name = null, curator, params = {}, roomKey = null, handle = null, by = null } = {}) {
92 this._assertWritable('addFeed')
93 if (!isDid(did)) throw new Error('addFeed requires a valid did (the feed record home repo)')
94 if (!isRkey(rkey)) throw new Error('addFeed requires a valid rkey')
95 if (typeof curator !== 'string' || !curator) throw new Error('addFeed requires a curator name')
96 if (params != null && (typeof params !== 'object' || Array.isArray(params))) throw new Error('addFeed params must be an object')
97 await this._room.append({ type: 'add-feed', did, rkey, name, curator, params: params || {}, roomKey, handle, by, at: Date.now() })
98 }
99
100 async removeFeed (did, rkey) {
101 this._assertWritable('removeFeed')
102 if (!isDid(did) || !isRkey(rkey)) throw new Error('removeFeed requires a valid did + rkey')
103 await this._room.append({ type: 'remove-feed', did, rkey })
104 }
105
106 // ── admission (owner adds a co-writer) — same model + warnings as CommunityRoom.addMod ──
107 // Default role 'writer' = a plain writer (non-indexer, the SAFE topology: owner stays sole indexer N=1, so
108 // the mod's ops still linearize via ackInterval without the N=2 quorum trap). role 'owner' promotes to
109 // INDEXER — only to reach an ODD indexer count ≥3 on independent always-on boxes.
110 async addMod (writerKeyHex, { role = 'writer', userId = null, addedBy = null } = {}) {
111 this._assertWritable('addMod')
112 if (!/^[0-9a-f]{64}$/i.test(String(writerKeyHex))) throw new Error('addMod requires the mod\'s 64-hex localKey')
113 if (!['owner', 'writer', 'mirror'].includes(role)) throw new Error('role must be owner, writer, or mirror')
114 await this._room.addWriter(userId || String(writerKeyHex).toLowerCase(), { writerKey: String(writerKeyHex).toLowerCase(), role, addedBy })
115 }
116
117 async removeMod (userId) { this._assertWritable('removeMod'); await this._room.removeWriter(userId) }
118
119 _assertWritable (op) { if (!this._room.writable) throw new Error(`${op}: not a writer — this peer holds no key / has not been admitted (reactors are keyless by design)`) }
120}
121
122// ── read-manager (reactors) ─────────────────────────────────────
123// Opens + maintains a KEYLESS read replica of a registry on a shared corestore + swarm, answers feeds(), and
124// WATCHES for changes. This is the reactor primitive: favela-web / membrane / hpp / blind-relay each wrap a
125// FeedRegistries and react to onChange(feeds[]) — index / front / serve / seed the new feed. No per-feed config.
126export class FeedRegistries {
127 constructor (corestore, swarm) {
128 this._store = corestore
129 this._swarm = swarm
130 this._registries = new Map() // roomKeyHex -> Promise<FeedRegistry>
131 // Replicate our corestore on every swarm connection (joining a discoveryKey only DISCOVERS; without
132 // this the connection forms but no cores flow and the list never syncs). Mirrors CommunityRooms.
133 if (swarm) swarm.on('connection', (conn) => { conn.on('error', () => {}); this._store.replicate(conn) })
134 }
135
136 registry (roomKeyHex) {
137 const key = normKey(roomKeyHex)
138 if (this._registries.has(key)) return this._registries.get(key)
139 const p = (async () => {
140 const fr = await FeedRegistry.openReadonly(this._store, key)
141 if (this._swarm) this._swarm.join(fr.discoveryKey, { client: true, server: true })
142 return fr
143 })().catch((e) => { this._registries.delete(key); throw e })
144 this._registries.set(key, p)
145 return p
146 }
147
148 async feeds (roomKeyHex) { return (await this.registry(roomKeyHex)).feeds() }
149
150 // Watch a registry for changes. onChange(feeds[]) fires (debounced) on every linearized-view advance — i.e.
151 // whenever a feed add/remove replicates in — PLUS once immediately as a baseline. This is what a reactor
152 // uses to pick up feeds published AFTER it started, with NO restart. Serialized + coalesced; onChange
153 // errors are swallowed (a flaky reactor must never break replication). Returns an async unwatch fn.
154 async watch (roomKeyHex, onChange, { debounceMs = 300 } = {}) {
155 const fr = await this.registry(roomKeyHex)
156 const base = fr.base
157 let timer = null
158 let running = false
159 let pending = false
160 const run = async () => {
161 if (running) { pending = true; return }
162 running = true
163 try { await onChange(await fr.feeds()) } catch {}
164 running = false
165 if (pending) { pending = false; schedule() }
166 }
167 const schedule = () => { clearTimeout(timer); timer = setTimeout(run, debounceMs) }
168 base.on('update', schedule)
169 schedule() // baseline: emit the current feed set now
170 return async () => { clearTimeout(timer); base.off('update', schedule) }
171 }
172
173 async close () {
174 for (const p of this._registries.values()) { try { await (await p).close() } catch {} }
175 this._registries.clear()
176 }
177}
178
179export default { FeedRegistry, FeedRegistries }
lib/feed-schema.js +64 -0
……@@ -0,0 +1,64 @@
1// The FEED REGISTRY protocol — the op contract for a watched list of feeds, as a hyper-rooms schema. This
2// is the single source of truth that makes "a feed exists" one durable P2P fact everyone reacts to
3// (favela-web/spec/community-ux.md): the mint writes an entry; favela-web (index), the membrane (front +
4// requestCrawl), the multi-DID hpp (serve co-owned ones), and the blind-relay (seed) all keyless-READ +
5// WATCH it — no per-feed config, no restart. Plaintext by design (feed identities are PUBLIC — a did + rkey
6// + curation contract; never a secret).
7//
8// A feed is the PRIMITIVE (a community is just the roster-curated, co-owned kind): { curator, params } + a
9// HOME repo (`did`) that holds the app.bsky.feed.generator record. For a light/arbitrary feed `did` is the
10// creator's OWN hpp repo (no new DID); for a community it's the co-owned DID. `roomKey` is present only for
11// roster-type feeds (the audience CommunityRoom favela-web reads).
12//
13// Domain ops (writer ops add-writer/remove-writer are handled by hyper-rooms Room itself):
14// add-feed {did,rkey,name,curator,params,roomKey?,handle?} → feed:<did>/<rkey>
15// remove-feed {did,rkey} → del feed:<did>/<rkey>
16//
17// apply is DETERMINISTIC — reads only fields carried in the op (op.at/op.by set at append time), never
18// Date.now()/randomness (Autobase replays apply on every boot + reorg).
19import { jsonSchema } from 'hyper-rooms/lib/json-schema.js'
20
21export const isDid = (d) => typeof d === 'string' && /^did:[a-z]+:/.test(d)
22// rkey syntax = [A-Za-z0-9._~-], 1–512, not "."/".."; keep it simple + safe for the `feed:<did>/<rkey>` key.
23export const isRkey = (r) => typeof r === 'string' && r.length > 0 && r.length <= 512 && /^[A-Za-z0-9._~-]+$/.test(r) && r !== '.' && r !== '..'
24const isPlainObject = (o) => o != null && typeof o === 'object' && !Array.isArray(o)
25
26export const FEED_PREFIX = 'feed:'
27export const feedKey = (did, rkey) => `${FEED_PREFIX}${did}/${rkey}`
28
29export const feedRegistrySchema = jsonSchema({
30 validate (op) {
31 if (op.type === 'add-feed') {
32 if (!isDid(op.did)) throw new Error('add-feed requires a valid did (the feed record home repo)')
33 if (!isRkey(op.rkey)) throw new Error('add-feed requires a valid rkey')
34 if (typeof op.curator !== 'string' || !op.curator) throw new Error('add-feed requires a curator name')
35 if (op.params != null && !isPlainObject(op.params)) throw new Error('add-feed params must be an object')
36 if (op.roomKey != null && !/^[0-9a-f]{64}$/i.test(String(op.roomKey))) throw new Error('add-feed roomKey must be a 32-byte hex key')
37 }
38 if (op.type === 'remove-feed') {
39 if (!isDid(op.did) || !isRkey(op.rkey)) throw new Error('remove-feed requires a valid did + rkey')
40 }
41 },
42 async apply (batch, op, host) {
43 switch (op.type) {
44 case 'add-feed':
45 await batch.put(feedKey(op.did, op.rkey), {
46 did: op.did,
47 rkey: op.rkey,
48 name: op.name || op.rkey,
49 curator: op.curator,
50 params: op.params || {},
51 roomKey: op.roomKey ? String(op.roomKey).toLowerCase() : null,
52 handle: op.handle || null,
53 createdBy: op.by || null,
54 at: op.at || null
55 })
56 break
57 case 'remove-feed':
58 await batch.del(feedKey(op.did, op.rkey))
59 break
60 }
61 }
62})
63
64export default feedRegistrySchema
package.json +1 -1
……@@ -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 && bare test/discovery.test.mjs",
11 "test": "bare test/community-room.test.mjs && bare test/feed-registry.test.mjs && bare test/cli.test.mjs && bare test/discovery.test.mjs",
1212 "e2e": "bare test/live-swarm-e2e.mjs"
1313 },
1414 "dependencies": {
test/feed-registry.test.mjs +94 -0
……@@ -0,0 +1,94 @@
1// favela-community — the FEED REGISTRY: the durable, co-ownable, keyless-readable list of feeds that every
2// reactor (favela-web index / membrane front+requestCrawl / hpp serve / blind-relay seed) watches, so "a
3// feed exists" is ONE P2P fact with no per-feed config (favela-web/spec/community-ux.md). Same three-role
4// shape as CommunityRoom: OWNER (create) / MOD (open + admitted) / READER (keyless openReadonly).
5// Bare-native (Autobase/Hyperbee need a disk path). Run: bare test/feed-registry.test.mjs
6import Corestore from 'corestore'
7import { FeedRegistry, FeedRegistries } from '../index.js'
8
9const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m) }
10const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
11const stamp = Date.now()
12
13// a light feed (creator's OWN hpp repo, no roomKey) + a community feed (co-owned DID + roster roomKey)
14const OWN = 'did:plc:owner222222222222222222222'
15const COMM = 'did:plc:community222222222222222'
16const ROSTER = 'a'.repeat(64) // a 32-byte hex CommunityRoom key
17
18// pipe two bases' replication streams together (in-process stand-in for the swarm)
19function wire (a, b) {
20 const s1 = a.base.replicate(true); const s2 = b.base.replicate(false)
21 s1.on('error', () => {}); s2.on('error', () => {})
22 s1.pipe(s2).pipe(s1)
23 return () => { try { s1.destroy() } catch {} try { s2.destroy() } catch {} }
24}
25async function until (fn, ms = 15000) { const end = Date.now() + ms; while (Date.now() < end) { if (await fn()) return true; await sleep(100) } return false }
26
27const ownerStore = new Corestore('data/fr-owner-' + stamp)
28const readerStore = new Corestore('data/fr-reader-' + stamp)
29const modStore = new Corestore('data/fr-mod-' + stamp)
30
31// ── OWNER: create + publish feeds ──
32const owner = await FeedRegistry.create(ownerStore, { name: 'Favela Feeds' })
33const REG = owner.key
34assert(/^[0-9a-f]{64}$/.test(REG), 'fresh registry has a 64-hex public bootstrap key')
35assert(owner.writable, 'creator is a writer (genesis indexer)')
36
37// a light feed (arbitrary curator, HOME = creator's own hpp, no roster) + a community feed (roster curator)
38await owner.addFeed({ did: OWN, rkey: 'my-p2p', name: 'p2p tag', curator: 'subject', params: { tag: 'p2p' } })
39await owner.addFeed({ did: COMM, rkey: 'loser-club', name: 'loser club', curator: 'roster', params: {}, roomKey: ROSTER })
40await owner.update()
41let of = await owner.feeds()
42assert(of.length === 2, 'owner lists both published feeds')
43const community = of.find((f) => f.rkey === 'loser-club')
44assert(community && community.did === COMM && community.curator === 'roster' && community.roomKey === ROSTER, 'community feed entry carries did + roster curator + roomKey')
45const light = of.find((f) => f.rkey === 'my-p2p')
46assert(light && light.curator === 'subject' && light.params.tag === 'p2p' && light.roomKey === null, 'light feed entry carries subject curator + params, roomKey null')
47const one = await owner.feed(COMM, 'loser-club')
48assert(one && one.name === 'loser club', 'feed(did,rkey) reads a single entry')
49
50// ── validation ──
51const rejects = async (fn, m) => { let t = false; try { await fn() } catch { t = true } assert(t, m) }
52await rejects(() => owner.addFeed({ did: 'not-a-did', rkey: 'x', curator: 'roster' }), 'addFeed rejects a bad did')
53await rejects(() => owner.addFeed({ did: OWN, rkey: '..', curator: 'roster' }), 'addFeed rejects a bad rkey')
54await rejects(() => owner.addFeed({ did: OWN, rkey: 'x', curator: '' }), 'addFeed rejects an empty curator')
55await rejects(() => owner.addFeed({ did: OWN, rkey: 'x', curator: 'roster', params: [1, 2] }), 'addFeed rejects non-object params')
56
57// ── remove-feed ──
58await owner.removeFeed(OWN, 'my-p2p')
59assert(await until(async () => (await owner.feeds()).length === 1), 'removeFeed drops the entry (last-writer-wins del)')
60
61// ── KEYLESS READER (the reactor path): openReadonly, replicates, never writes ──
62const reader = await FeedRegistry.openReadonly(readerStore, REG)
63assert(!reader.writable, 'keyless reader is NOT writable (holds no key)')
64const unwireR = wire(owner, reader)
65assert(await until(async () => (await reader.feeds()).some((f) => f.rkey === 'loser-club')), 'KEYLESS READ: reader replicates the feed list (no key)')
66await rejects(() => reader.addFeed({ did: OWN, rkey: 'evil', curator: 'roster' }), 'keyless reader REFUSES to write (addFeed throws — reactors are keyless)')
67
68// ── MOD: admitted by the owner, co-publishes (a registry can be co-owned) ──
69const mod = await FeedRegistry.open(modStore, REG)
70assert(!mod.writable, 'mod is not writable before admission')
71const unwireM = wire(owner, mod)
72await owner.addMod(mod.localKey, { userId: 'mod-b' })
73assert(await mod.waitWritable({ timeout: 15000 }), 'mod becomes writable after owner.addMod (admission)')
74await mod.addFeed({ did: COMM, rkey: 'mod-feed', name: 'mod feed', curator: 'roster', params: {}, roomKey: ROSTER })
75assert(await until(async () => (await owner.feeds()).some((f) => f.rkey === 'mod-feed')), 'WRITE LAYER: owner sees the mod\'s addFeed — co-writer ops linearize through the owner')
76assert(await until(async () => (await reader.feeds()).some((f) => f.rkey === 'mod-feed')), 'the keyless reader also converges on the mod\'s addFeed')
77
78// ── REACTOR WATCHER: a feed published AFTER watch starts reaches the reactor with NO restart (the design goal) ──
79const watchStore = new Corestore('data/fr-watch-' + stamp)
80const regs = new FeedRegistries(watchStore, null) // no swarm — wire replication manually below
81const seen = []
82const unwatch = await regs.watch(REG, (feeds) => { seen.push(feeds.map((f) => f.rkey).sort()) }, { debounceMs: 50 })
83const watchReader = await regs.registry(REG) // same replica watch() opened
84const unwireW = wire(owner, watchReader)
85assert(await until(() => seen.length > 0 && seen[seen.length - 1].includes('loser-club')), 'watch BASELINE: reactor sees the current feed set after replication')
86await owner.addFeed({ did: COMM, rkey: 'fresh', name: 'fresh feed', curator: 'roster', params: {}, roomKey: ROSTER })
87assert(await until(() => seen[seen.length - 1].includes('fresh')), 'watch DELTA: a feed published AFTER watch started reaches the reactor with NO re-open')
88await unwatch(); unwireW()
89
90console.log('\nFAVELA-COMMUNITY FEED-REGISTRY OK (Bare-native): owner publish + validation + remove + keyless read + mod admission (co-owned) + reactor watcher (baseline + delta)')
91unwireR(); unwireM()
92await owner.close(); await reader.close(); await mod.close()
93try { await regs.close() } catch {}
94try { await ownerStore.close(); await readerStore.close(); await modStore.close(); await watchStore.close() } catch {}