OverviewFilesCommitsIssuesDocsReleasesPeers

favela-community / lib / community-room.js

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