// CommunityRoom — a co-owned, plaintext community room on a hyper-rooms Autobase.
//
// One class, three roles:
// - OWNER (create): genesis writer + sole indexer; manages membership + admits other mods.
// - MOD (open + admitted): a co-writer; appends membership/moderation ops after being addWritered.
// - READER (openReadonly): KEYLESS — opens by the room's PUBLIC bootstrapKey, holds no key, never a
// writer; replicates and derives the roster. This is favela-web's path.
//
// Membership + moderation linearize into a plaintext Hyperbee view (schema.js). Posts are NOT here — they
// live in each member's own signed repo. See schema.js for the "why plaintext" rationale.
//
// hyper-rooms `Room` is imported by SUBPATH (not the barrel) to skip its room-pairing → blind-pairing dep,
// which the read/write paths here don't use (mod admission is key-exchange, below).
import { Room } from 'hyper-rooms/lib/room.js'
import { communitySchema, isDid, DEFAULT_CURATION } from './schema.js'
const MEMBER = 'member:'
const BANNED = 'banned:'
const META = 'meta:'
const normKey = (k) => (typeof k === 'string' ? k.toLowerCase() : k)
export class CommunityRoom {
constructor (room) { this._room = room }
// OWNER — create a fresh community room. This peer is the genesis writer AND the sole indexer (N=1, the
// safe default per the autobase quorum rule — never auto-grow to N=2). Optionally set the name and the
// community's atproto DID (see communityDid below).
static async create (corestore, { namespace = 'community', name = null, did = null, curator = null, params = {} } = {}) {
const room = new Room(corestore, { schema: communitySchema, bootstrapKey: null, namespace })
await room.ready()
const cr = new CommunityRoom(room)
if (name) await cr.setMeta('name', name)
if (did) await cr.setMeta('did', did)
if (curator) await cr.setCuration(curator, params)
return cr
}
// MOD — open an EXISTING room to co-write. Becomes writable only AFTER the owner addMods this peer's
// localKey (call waitWritable() to block until then). Until admitted it can still READ.
static async open (corestore, roomKey, { namespace } = {}) {
const key = normKey(roomKey)
if (!/^[0-9a-f]{64}$/.test(String(key))) throw new Error('roomKey must be a 32-byte hex bootstrap key')
const room = new Room(corestore, { schema: communitySchema, bootstrapKey: key, namespace: namespace || `community:${key}` })
await room.ready()
return new CommunityRoom(room)
}
// READER — keyless read-only open (semantically identical to open(); named for intent). favela-web uses
// this: it never calls a write method, so it is never admitted and holds no key.
static openReadonly (corestore, roomKey, opts) { return CommunityRoom.open(corestore, roomKey, opts) }
// ── identity / replication surface ──
get key () { return this._room.keyHex } // PUBLIC bootstrap key — share to invite mods/readers
get localKey () { return this._room.localKeyHex } // THIS peer's writer key — give to the owner for addMod
get discoveryKey () { return this._room.discoveryKey }
get writable () { return this._room.writable }
get corestore () { return this._room.corestore }
get base () { return this._room.base }
get view () { return this._room.view }
async update () { await this._room.base.update() }
async close () { await this._room.close() }
// Block until this peer has been admitted as a writer (autopass whenWritable pattern). Returns false on
// timeout. A mod calls this after handing its localKey to the owner, before writing.
waitWritable ({ timeout = 30000 } = {}) {
const base = this._room.base
if (base.writable) return Promise.resolve(true)
return new Promise((resolve) => {
let done = false
const finish = (v) => { if (done) return; done = true; base.off('update', check); clearTimeout(timer); resolve(v) }
const check = () => { if (base.writable) finish(true) }
const timer = setTimeout(() => finish(false), timeout)
base.on('update', check)
})
}
// ── reads (keyless-safe) ──
async members () {
await this._room.base.update()
const banned = new Set()
for await (const { key } of this._room.view.createReadStream({ gte: BANNED, lt: BANNED + '\xff' })) banned.add(key.slice(BANNED.length))
const out = []
for await (const { key } of this._room.view.createReadStream({ gte: MEMBER, lt: MEMBER + '\xff' })) {
const did = key.slice(MEMBER.length)
if (!banned.has(did)) out.push(did)
}
return out
}
async isBanned (did) { await this._room.base.update(); return !!(await this._room.view.get(BANNED + did)) }
// The community's atproto DID (meta:did) — the CO-OWNED identity that owns/signs this community's
// app.bsky.feed.generator + app.bsky.labeler.service records and its com.atproto.label labels (the
// lens export, spec/lens-export.md §3). It is a SEPARATE substrate from this room: the DID is a
// co-owned hyper-proto-peer (StateBase admins + Lease + Signer/LabelRoom = the multiswarm-PDS pattern);
// this room is the keyless MEMBER roster. The room's MODS are the DID's admin/signer set — one attested
// group surfaced two ways (mods here; StateBase device-writers there). Returns null until set at create
// or via setDid. Keyless-safe (a plain meta read).
async communityDid () { return (await this.meta()).did || null }
// Owner/mod: bind (or rebind) the community's atproto DID. Written like any meta op, so it linearizes +
// replicates to keyless readers. Set once at create normally; this is for late-binding / migration.
async setDid (did) { this._assertWritable('setDid'); this._assertDid(did); await this.setMeta('did', did) }
// The curation CONTRACT — { curator, params } — the mutable rule for HOW this community's feed selects
// posts (roster / subject / rules / custom), which favela-web's curator registry runs to drive
// getFeedSkeleton (architecture.md "Feeds are curation functions"). THREE distinct substrates, don't
// conflate: the ROSTER is WHO (members()), the DID is the signing identity (communityDid()), the CURATION
// is HOW (here). Defaults to roster curation (the historical implicit behavior) until set, so pre-curation
// rooms keep working with NO migration. Keyless-safe (a plain view read) and bsky-neutral (it only affects
// which app.bsky.feed.post URIs the feed returns; bsky never sees the curator).
async curation () {
await this._room.base.update()
const node = await this._room.view.get('curation')
return (node && node.value) || { ...DEFAULT_CURATION }
}
// Owner/mod: set (or change) the curation contract. Mutable — an autobase op, so it linearizes + replicates
// to keyless readers like any other. curator = any non-empty string (a built-in name or a custom curator
// id — the schema is curator-agnostic); params = that curator's config object.
async setCuration (curator, params = {}) {
this._assertWritable('setCuration')
if (typeof curator !== 'string' || !curator) throw new Error('setCuration requires a curator name')
if (params != null && (typeof params !== 'object' || Array.isArray(params))) throw new Error('setCuration params must be an object')
await this._room.append({ type: 'set-curation', curator, params: params || {} })
}
async meta () {
await this._room.base.update()
const m = {}
for await (const { key, value } of this._room.view.createReadStream({ gte: META, lt: META + '\xff' })) m[key.slice(META.length)] = value && value.value
return m
}
// The co-writers (mods) — { userId, role, writerKey, addedBy, addedAt }. role 'owner' = indexer.
async mods () { return this._room.listWriters() }
// ── writes (mods; throw if not admitted) ──
async addMember (did, { by = null } = {}) { this._assertWritable('addMember'); this._assertDid(did); await this._room.append({ type: 'add-member', did, by, at: Date.now() }) }
async removeMember (did) { this._assertWritable('removeMember'); this._assertDid(did); await this._room.append({ type: 'remove-member', did }) }
async ban (did, { by = null } = {}) { this._assertWritable('ban'); this._assertDid(did); await this._room.append({ type: 'ban', did, by, at: Date.now() }) }
async unban (did) { this._assertWritable('unban'); this._assertDid(did); await this._room.append({ type: 'unban', did }) }
async setMeta (key, value) { this._assertWritable('setMeta'); await this._room.append({ type: 'set-meta', key, value }) }
// ── admission (owner adds a co-writer) ──
// Add another mod as a co-writer. role 'writer' (DEFAULT) = a plain writer (indexer:false) — the SAFE
// choice: the owner stays the sole indexer (N=1), so the mod's ops still linearize (owner acks via
// ackInterval) without risking the N=2 quorum trap. role 'owner' promotes to INDEXER — only do this to
// reach an ODD indexer count ≥3 (a real anchor), NEVER as the 2nd indexer (see addIndexer's warning).
async addMod (writerKeyHex, { role = 'writer', userId = null, addedBy = null } = {}) {
this._assertWritable('addMod')
if (!/^[0-9a-f]{64}$/i.test(String(writerKeyHex))) throw new Error('addMod requires the mod\'s 64-hex localKey')
if (!['owner', 'writer', 'mirror'].includes(role)) throw new Error('role must be owner, writer, or mirror')
await this._room.addWriter(userId || String(writerKeyHex).toLowerCase(), { writerKey: String(writerKeyHex).toLowerCase(), role, addedBy })
}
// Promote a co-writer to INDEXER (consensus participant). ⚠ AUTOBASE QUORUM: majority = floor(N/2)+1, so
// N=2 DEADLOCKS if either indexer drops (autobase forbids 2→1). Only grow the indexer set to an ODD count
// ≥3 on independent, always-on boxes (e.g. an anchor). This helper is the explicit, eyes-open path.
async addIndexer (writerKeyHex, { userId = null, addedBy = null } = {}) {
return this.addMod(writerKeyHex, { role: 'owner', userId, addedBy })
}
async removeMod (userId) { this._assertWritable('removeMod'); await this._room.removeWriter(userId) }
_assertWritable (op) { if (!this._room.writable) throw new Error(`${op}: not a writer — this peer holds no key / has not been admitted (favela-web is keyless by design)`) }
_assertDid (did) { if (!isDid(did)) throw new Error('a valid did is required') }
}
// ── read-manager (favela-web server) ───────────────────────────
// Opens + maintains a KEYLESS read replica per community room on a shared corestore + swarm, and answers
// members(roomKey). This is the `Communities.resolve` source for co-owned communities: the server wires
// `resolve = (community) => communityRooms.members(community.roomKey)`.
export class CommunityRooms {
constructor (corestore, swarm) {
this._store = corestore
this._swarm = swarm
this._rooms = new Map() // roomKeyHex -> Promise<CommunityRoom>
// Replicate our corestore (all opened room replicas) on every swarm connection. Joining a room's
// discoveryKey only DISCOVERS peers — without this the connection forms but no cores flow and the
// roster never syncs. corestore.replicate muxes over the connection (composes with other consumers of
// a shared swarm, e.g. favela-web's DidPeer router). Errors are swallowed (peer resets on close).
if (swarm) swarm.on('connection', (conn) => { conn.on('error', () => {}); this._store.replicate(conn) })
}
room (roomKeyHex) {
const key = normKey(roomKeyHex)
if (this._rooms.has(key)) return this._rooms.get(key)
const p = (async () => {
const cr = await CommunityRoom.openReadonly(this._store, key)
if (this._swarm) this._swarm.join(cr.discoveryKey, { client: true, server: true })
return cr
})().catch((e) => { this._rooms.delete(key); throw e }) // don't cache a failed open
this._rooms.set(key, p)
return p
}
async members (roomKeyHex) { return (await this.room(roomKeyHex)).members() }
async meta (roomKeyHex) { return (await this.room(roomKeyHex)).meta() }
async communityDid (roomKeyHex) { return (await this.room(roomKeyHex)).communityDid() }
async curation (roomKeyHex) { return (await this.room(roomKeyHex)).curation() }
// Watch one room's roster for changes. onChange(memberDids[]) fires (debounced) on every linearized-view
// advance — i.e. whenever a member add/remove/ban replicates in — PLUS once immediately as a baseline.
// This is the roster-watcher a keyless consumer (favela-web) uses to index members added AFTER it started,
// with no restart: the same `base.on('update')` signal waitWritable relies on, which fires on replication.
// Runs are serialized (never re-entrant) and coalesced (a change during a run re-fires once after). Errors
// from onChange are swallowed — a flaky consumer must never break replication. Returns an async unwatch fn.
async watch (roomKeyHex, onChange, { debounceMs = 300 } = {}) {
const cr = await this.room(roomKeyHex)
const base = cr.base
let timer = null
let running = false
let pending = false
const run = async () => {
if (running) { pending = true; return }
running = true
try { await onChange(await cr.members()) } catch {}
running = false
if (pending) { pending = false; schedule() }
}
const schedule = () => { clearTimeout(timer); timer = setTimeout(run, debounceMs) }
base.on('update', schedule)
schedule() // baseline: index the current roster now
return async () => { clearTimeout(timer); base.off('update', schedule) }
}
async close () {
for (const p of this._rooms.values()) { try { await (await p).close() } catch {} }
this._rooms.clear()
}
}
export default { CommunityRoom, CommunityRooms }