OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer / lib / room.js

549 lines · javascript ref:
// A room — many members, roles, and membership as the audience. ROOM-CONTRACT v2.
//
// ⛔ THE DISTINCTION FROM A FEED. `feed.js`'s roster is DEVICE-FLAT: one identity, writers are its devices.
// A room has many MEMBERS and each member has devices, so there are two levels and every question is asked
// at the right one — a role belongs to a person, not to a laptop.
//
// ── ROOM-CONTRACT v2 (hyper-social/docs/ROOM-CONTRACT.md) ─────────────────────────────────────────
//
// v1 adopted Keet's WORDS (admin / moderator / peer / read-only) and not its ENFORCEMENT: it authorised an
// op by the identity root signed into the payload, so a correctly signed op was valid every time it
// appeared, from anyone — a captured `add-member` re-admitted a member a moderator had removed
// (test/room-replay.test.js, red on 0.0.62). v2 is Keet's model:
//
//  ⛔ AUTHORITY IS THE CARRIER. An op's actor is the member owning the WRITER CORE that carried it:
//     `__devices__/<writer>` → member → role. Only that device can append to its core, so a replay from any
//     other core is somebody else's op, judged as theirs. Room ops carry no signature and no `root`.
//  ⛔ Signatures remain only where a NEW writer core is bound to a member: `found`, `join`, `add-device`.
//     Verified once, at admission; afterwards the core is the credential.
//  ⛔ AUTHORITY IS THE ROLE, NEVER THE WEIGHT. autobee 2.12.1 cannot lower a writer's weight (measured,
//     ROOM-CONTRACT §3.3), so apply never reads weight to decide a permission. Admin devices are granted
//     weight 2 (ordering priority); a demoted admin loses its powers in the same apply and keeps only the
//     tie-break priority.
//  ⛔ JOINING IS BY INVITATION, on the joiner's OWN block (ROOM-CONTRACT §4, measured on 2.12.1): the
//     joiner's `addWriter(self)` is a pending promotion that the next block of any weighted member anchors.
//
// Decided 2026-10-13: a ban is enforced in apply (liftable); join requests are accepted by moderators and
// admins; `read-only` stays a role; v1 rooms are not migrated (a v1 op is ignored).

import Autobee from 'autobee'
import b4a from 'b4a'
import crypto from 'hypercore-crypto'
import { readAttested, signAttested } from './attestation.js'
import { recordName } from './name.js'

const SEP = '/'
const FOUNDED = '__founded__'
const MEMBER = '__members__'
const DEVICE = '__devices__' // ⛔ keyed by WRITER key — the lookup authority-by-carrier needs
const INVITE = '__invites__'
const REQUEST = '__requests__' // keyed by the joiner's writer key
const SETTINGS = '__settings__'
const FLAG = '__flags__'
const MSG = 'message'
// ⛔ A community is a ROOM WHOSE LISTINGS ARE ROOMS — convention, not a type.
const LISTING = 'listing'

export const READ_ONLY = 'read-only'
export const PEER = 'peer'
export const MODERATOR = 'moderator'
export const ADMIN = 'admin'
const RANK = { [READ_ONLY]: 0, [PEER]: 1, [MODERATOR]: 2, [ADMIN]: 3 }
const isRole = (r) => Object.prototype.hasOwnProperty.call(RANK, r)

// ⛔ A CLOSED set (ROOM-CONTRACT §3.9). Authority-bearing state goes where apply can gate it; adding a key
// bumps OP_VERSION. `public` / `bridgedTo` / `curation` are deliberately deferred (§8, with the gateway).
const SETTINGS_KEYS = new Set(['title', 'description', 'avatar', 'supportedLanguages', 'blindPeers',
  'isJoiningDisabled', 'isAppendingDisabled', 'inviteHidden'])

export const OP_VERSION = 2
const enc = (v) => b4a.from(JSON.stringify(v), 'utf8')
const dec = (b) => (b == null ? null : JSON.parse(b4a.toString(b, 'utf8')))
const hex = (k) => (typeof k === 'string' ? k : b4a.toString(k, 'hex'))
const isKeyHex = (s) => typeof s === 'string' && /^[0-9a-f]{64}$/i.test(s)
const isSeg = (s) => typeof s === 'string' && s.length > 0 && !s.includes(SEP) && s !== '.' && s !== '..'
const truthy = (v) => v === true || v === 'true' || v === 1

const memberKey = (root) => MEMBER + SEP + root
const deviceKey = (writer) => DEVICE + SEP + writer
const inviteKey = (id) => INVITE + SEP + id
const requestKey = (writer) => REQUEST + SEP + writer
const msgKey = (name) => MSG + SEP + name
const flagKey = (target, by) => FLAG + SEP + target + SEP + by
const listingKey = (room) => LISTING + SEP + room

/** The bytes a `join` signature covers: every field except `sig`, keys sorted. */
export function canonicalOpBytes (op) {
  const ordered = {}
  for (const k of Object.keys(op).sort()) if (k !== 'sig') ordered[k] = op[k]
  return b4a.from(JSON.stringify(ordered), 'utf8')
}

// Weight follows the role at the moment it is granted. ⚠ It can rise, never fall (see the header).
const weightFor = (role) => ({ isIndexer: role === ADMIN })

/** The { get, set, scan } surface apply wants, over one hyperbee2 batch. */
function batchView (bee, batch) {
  const pending = new Map()
  return {
    async get (key) {
      if (pending.has(key)) return pending.get(key)
      const node = await bee.get(b4a.from(key, 'utf8'))
      return node ? dec(node.value) : null
    },
    async set (key, value) {
      pending.set(key, value)
      batch.tryPut(b4a.from(key, 'utf8'), enc(value))
    },
    async scan (prefix) {
      const out = new Map()
      const p = b4a.from(prefix, 'utf8')
      for await (const node of bee.createReadStream({ gte: p, lt: b4a.concat([p, b4a.from([0xff])]) })) {
        out.set(b4a.toString(node.key, 'utf8'), dec(node.value))
      }
      for (const [k, v] of pending) if (k.startsWith(prefix)) out.set(k, v)
      return [...out].map(([key, value]) => ({ key, value })).filter((e) => e.value != null)
    }
  }
}

/**
 * The member acting through this writer core, or null.
 *
 * ⛔ THE ONE AUTHORISATION RULE (ROOM-CONTRACT §3.1.1): unknown writer, inactive device, or inactive or
 * removed member → the op is ignored.
 */
async function actorOf (b, writer) {
  const d = await b.get(deviceKey(writer))
  if (!d || !d.isActive) return null
  const m = await b.get(memberKey(d.member))
  if (!m || !m.isActive || m.isRemoved) return null
  return { root: d.member, role: m.role, muted: !!m.isMuted, banned: !!m.isBanned }
}

const atLeast = (actor, role) => !!actor && RANK[actor.role] >= RANK[role]

async function adminCount (b, except = null) {
  let n = 0
  for (const e of await b.scan(MEMBER + SEP)) {
    const root = e.key.slice(MEMBER.length + 1)
    if (root === except) continue
    if (e.value && e.value.isActive && !e.value.isRemoved && e.value.role === ADMIN) n++
  }
  return n
}

// Every ACTIVE device of a member, as [{ key, value }].
async function devicesOf (b, root) {
  return (await b.scan(DEVICE + SEP)).filter((e) => e.value && e.value.member === root && e.value.isActive)
}

/** Make `root` an active member at `role` and bind this writer core to it. Used by join and accept-join. */
async function admit (b, host, { root, device, writer, role }) {
  const prior = await b.get(memberKey(root))
  await b.set(memberKey(root), {
    role,
    isActive: true,
    isRemoved: false,
    isMuted: prior ? !!prior.isMuted : false,
    isBanned: prior ? !!prior.isBanned : false
  })
  await b.set(deviceKey(writer), { member: root, device, isActive: true })
  try { host.addWriter(b4a.from(writer, 'hex'), weightFor(role)) } catch {}
}

/** The role an invitation grants now. ⛔ An admin/moderator grant is single-use (Keet `:169-171`). */
const grantOf = (inv) => (RANK[inv.role] >= RANK[MODERATOR] && inv.used ? PEER : inv.role)

async function applyOps (batch, view, host) {
  const write = view.write()
  const b = batchView(view, write)

  for (const node of batch) {
    let op
    try { op = dec(node.value) } catch { continue }
    if (!op || typeof op !== 'object' || op.v !== OP_VERSION) continue
    const writer = node.key ? hex(node.key).toLowerCase() : null
    if (!writer) continue
    const writerBytes = b4a.from(writer, 'hex')

    // ── genesis: the founder binds its own writer core ──────────────────────────────────────────
    if (op.t === 'found') {
      if (await b.get(FOUNDED)) continue
      const proof = readAttested(op.bound, writerBytes)
      if (!proof) continue
      await b.set(FOUNDED, { member: proof.root })
      await b.set(memberKey(proof.root), { role: ADMIN, isActive: true, isRemoved: false, isMuted: false, isBanned: false })
      await b.set(deviceKey(writer), { member: proof.root, device: proof.device, isActive: true })
      try { host.addWriter(node.key, weightFor(ADMIN)) } catch {}
      // A DM is a room founded with both members (Keet `internal.js:57-64`); the other person's node then
      // binds its own core with `add-device`.
      for (const m of Array.isArray(op.initialMembers) ? op.initialMembers : []) {
        const root = m && String(m.root || '').toLowerCase()
        if (!isKeyHex(root) || root === proof.root) continue
        const role = isRole(m.role) && m.role !== ADMIN ? m.role : PEER
        await b.set(memberKey(root), { role, isActive: true, isRemoved: false, isMuted: false, isBanned: false })
      }
      continue
    }

    // ── joining, on the joiner's OWN block (§3.6) ───────────────────────────────────────────────
    if (op.t === 'join') {
      if (!isKeyHex(op.invite) || typeof op.sig !== 'string') continue
      const settings = (await b.get(SETTINGS)) || {}
      if (truthy(settings.isJoiningDisabled)) continue
      const id = op.invite.toLowerCase()
      const inv = await b.get(inviteKey(id))
      if (!inv || !inv.isActive) continue // ⛔ unlike Keet, a revoked invitation does not admit
      let ok = false
      try { ok = crypto.verify(canonicalOpBytes(op), b4a.from(op.sig, 'hex'), b4a.from(id, 'hex')) } catch {}
      if (!ok) continue
      // ⛔ The attestation must name THE CARRIER: a join lifted onto another core binds nothing.
      const proof = readAttested(op.bound, writerBytes)
      if (!proof) continue
      const m = await b.get(memberKey(proof.root))
      if (m && m.isRemoved) continue
      if (await b.get(deviceKey(writer))) continue
      if (inv.requiresConfirmation) {
        await b.set(requestKey(writer), { invite: id, member: proof.root, device: proof.device })
        continue
      }
      await admit(b, host, { root: proof.root, device: proof.device, writer, role: grantOf(inv) })
      if (RANK[inv.role] >= RANK[MODERATOR] && !inv.used) await b.set(inviteKey(id), { ...inv, used: true })
      continue
    }

    // ── a member's new device, on that device's own block ───────────────────────────────────────
    if (op.t === 'add-device') {
      const proof = readAttested(op.bound, writerBytes)
      if (!proof) continue
      const m = await b.get(memberKey(proof.root))
      if (!m || !m.isActive || m.isRemoved) continue
      if (await b.get(deviceKey(writer))) continue
      await b.set(deviceKey(writer), { member: proof.root, device: proof.device, isActive: true })
      try { host.addWriter(node.key, weightFor(m.role)) } catch {}
      continue
    }

    // ── everything else: the actor is whoever owns the carrying core ─────────────────────────────
    const actor = await actorOf(b, writer)
    if (!actor) continue

    if (op.t === 'invite') {
      if (!isKeyHex(op.id) || !isRole(op.role)) continue
      if (actor.muted || actor.banned) continue
      const settings = (await b.get(SETTINGS)) || {}
      if (truthy(settings.inviteHidden) && !atLeast(actor, MODERATOR)) continue
      const id = op.id.toLowerCase()
      if (await b.get(inviteKey(id))) continue
      // Capped at the inviter's own role (Keet `:215-216`).
      const role = RANK[op.role] > RANK[actor.role] ? actor.role : op.role
      await b.set(inviteKey(id), { by: actor.root, role, requiresConfirmation: !!op.requiresConfirmation, isActive: true, used: false })
      continue
    }

    if (op.t === 'uninvite') {
      if (!isKeyHex(op.id)) continue
      const inv = await b.get(inviteKey(op.id.toLowerCase()))
      if (!inv) continue
      if (inv.by !== actor.root && !atLeast(actor, MODERATOR)) continue
      await b.set(inviteKey(op.id.toLowerCase()), { ...inv, isActive: false })
      continue
    }

    if (op.t === 'accept-join' || op.t === 'reject-join') {
      if (!isKeyHex(op.writer)) continue
      if (!atLeast(actor, MODERATOR)) continue // decided 2026-10-13: moderators and admins, not any member
      const w = op.writer.toLowerCase()
      const req = await b.get(requestKey(w))
      if (!req || req.done) continue
      await b.set(requestKey(w), { ...req, done: op.t === 'accept-join' ? 'accepted' : 'rejected' })
      if (op.t === 'reject-join') continue
      const inv = await b.get(inviteKey(req.invite))
      if (!inv || !inv.isActive) continue
      const m = await b.get(memberKey(req.member))
      if (m && m.isRemoved) continue
      if (await b.get(deviceKey(w))) continue
      await admit(b, host, { root: req.member, device: req.device, writer: w, role: grantOf(inv) })
      if (RANK[inv.role] >= RANK[MODERATOR] && !inv.used) await b.set(inviteKey(req.invite), { ...inv, used: true })
      continue
    }

    if (op.t === 'remove-device') {
      if (!isKeyHex(op.writer)) continue
      const w = op.writer.toLowerCase()
      const d = await b.get(deviceKey(w))
      if (!d || !d.isActive || d.member !== actor.root) continue // ⛔ only the member owning it
      await b.set(deviceKey(w), { ...d, isActive: false })
      try { host.removeWriter(b4a.from(w, 'hex')) } catch {}
      continue
    }

    if (op.t === 'set-role') {
      const target = String(op.member || '').toLowerCase()
      if (!isKeyHex(target) || !isRole(op.role)) continue
      if (!atLeast(actor, MODERATOR)) continue
      const m = await b.get(memberKey(target))
      if (!m || !m.isActive || m.isRemoved) continue
      // ⛔ Touching an admin, or granting admin, takes an admin (Keet `:388`).
      if ((m.role === ADMIN || op.role === ADMIN) && !atLeast(actor, ADMIN)) continue
      // ⛔ Never the last admin: a room with no admin can never grant admin again.
      if (m.role === ADMIN && op.role !== ADMIN && (await adminCount(b, target)) === 0) continue
      await b.set(memberKey(target), { ...m, role: op.role })
      // Granting admin raises each active device to weight 2. ⚠ Demotion cannot lower it (header).
      if (op.role === ADMIN && m.role !== ADMIN) {
        for (const e of await devicesOf(b, target)) {
          try { host.addWriter(b4a.from(e.key.slice(DEVICE.length + 1), 'hex'), weightFor(ADMIN)) } catch {}
        }
      }
      continue
    }

    if (op.t === 'set-status') {
      const target = String(op.member || '').toLowerCase()
      if (!isKeyHex(target)) continue
      if (!atLeast(actor, MODERATOR)) continue
      const m = await b.get(memberKey(target))
      if (!m || m.isRemoved) continue
      if (m.role === ADMIN && !atLeast(actor, ADMIN)) continue
      const next = { ...m }
      if (op.muted !== undefined) next.isMuted = !!op.muted
      if (op.banned !== undefined) next.isBanned = !!op.banned
      await b.set(memberKey(target), next)
      continue
    }

    if (op.t === 'remove-member') {
      const target = String(op.member || '').toLowerCase()
      if (!isKeyHex(target)) continue
      const m = await b.get(memberKey(target))
      if (!m || m.isRemoved || !m.isActive) continue
      const self = target === actor.root
      if (!self && !atLeast(actor, MODERATOR)) continue
      // ⛔ An admin is removed only by themself (Keet SELF_ONLY), and never the last one.
      if (m.role === ADMIN && !self) continue
      if (m.role === ADMIN && (await adminCount(b, target)) === 0) continue
      // By a moderator: removed for good. By themself: left — reversible through an invitation.
      await b.set(memberKey(target), { ...m, isActive: false, isRemoved: !self })
      for (const e of await devicesOf(b, target)) {
        await b.set(e.key, { ...e.value, isActive: false })
        try { host.removeWriter(b4a.from(e.key.slice(DEVICE.length + 1), 'hex')) } catch {}
      }
      continue
    }

    if (op.t === 'message') {
      if (!isSeg(op.name)) continue
      // ⛔ PEER and up, not muted, not banned. A read-only member reads and flags.
      if (!atLeast(actor, PEER) || actor.muted || actor.banned) continue
      const settings = (await b.get(SETTINGS)) || {}
      if (truthy(settings.isAppendingDisabled) && !atLeast(actor, MODERATOR)) continue
      // ⛔ A name held by somebody else is refused; the same author revises their own.
      const prior = await b.get(msgKey(op.name))
      if (prior && prior.author !== actor.root) continue
      if (prior && prior.deleted) continue
      await b.set(msgKey(op.name), { author: actor.root, body: String(op.body || ''), at: typeof op.at === 'number' ? op.at : 0 })
      continue
    }

    if (op.t === 'delete-message') {
      if (!isSeg(op.name)) continue
      const prior = await b.get(msgKey(op.name))
      if (!prior || prior.deleted) continue
      if (prior.author !== actor.root && !atLeast(actor, MODERATOR)) continue
      // The body leaves the view; the tombstone says who removed it.
      await b.set(msgKey(op.name), { author: prior.author, deleted: true, by: actor.root, at: prior.at })
      continue
    }

    if (op.t === 'flag') {
      if (typeof op.target !== 'string' || op.target.length === 0 || op.target.length > 256) continue
      if (actor.muted) continue
      await b.set(flagKey(op.target, actor.root), { reason: String(op.reason || '').slice(0, 280), at: typeof op.at === 'number' ? op.at : 0 })
      continue
    }

    if (op.t === 'settings') {
      if (!SETTINGS_KEYS.has(op.key)) continue
      if (!atLeast(actor, MODERATOR)) continue
      const cur = (await b.get(SETTINGS)) || {}
      await b.set(SETTINGS, { ...cur, [op.key]: String(op.value == null ? '' : op.value).slice(0, 512) })
      continue
    }

    // ⛔ A MODERATOR act (COMMUNITY-MODERATION): curate listings, never moderate inside a listed room.
    if (op.t === 'list-room') {
      if (!isKeyHex(op.listed) || !atLeast(actor, MODERATOR)) continue
      await b.set(listingKey(op.listed.toLowerCase()), { room: op.listed.toLowerCase(), note: String(op.note || '').slice(0, 280), by: actor.root })
      continue
    }

    if (op.t === 'delist-room') {
      if (!isKeyHex(op.listed) || !atLeast(actor, MODERATOR)) continue
      const l = await b.get(listingKey(op.listed.toLowerCase()))
      if (!l) continue
      await b.set(listingKey(op.listed.toLowerCase()), { ...l, removed: true })
      continue
    }
  }

  await write.flush()
}

export class Room {
  constructor (store, key = null) {
    this.base = new Autobee(store, key || null, {
      apply: (batch, view, host) => applyOps(batch, view, host),
      // A joiner's block has to reach apply before anybody can judge it.
      optimistic: true,
      fastForward: false
    })
  }

  async ready () { await this.base.ready(); return this }
  get key () { return this.base.key }
  get discoveryKey () { return this.base.discoveryKey }
  get writer () { return hex(this.base.local.key).toLowerCase() }
  async update () { await this.base.update() }
  replicate (s) { return this.base.replicate(s) }
  async close () { await this.base.close() }

  // Ops carry no signature: the carrying core is the credential. `cred` parameters on ops other than
  // found/join/addDevice are accepted and ignored, so v1 callers keep working while they move.
  _op (t, extra) { return enc({ ...extra, t, v: OP_VERSION, at: Date.now() }) }
  _bind (cred) {
    if (!cred) throw new Error('this needs a device credential — run `favela link`')
    return signAttested(b4a.from(this.writer, 'hex'), cred)
  }

  /**
   * Found the room; this device's identity becomes its first admin. ⛔ Non-optimistic: an optimistic writer
   * cannot be genesis. `initialMembers` [{root, role}] — a DM is a room founded with both people.
   */
  async found (cred, { initialMembers = [] } = {}) {
    await this.base.append(this._op('found', { bound: this._bind(cred), initialMembers }))
  }

  /** Mint an invitation. Returns the TOKEN to hand to the joiner (it carries the signing secret). */
  async invite ({ role = PEER, requiresConfirmation = false } = {}) {
    const kp = crypto.keyPair()
    const id = hex(kp.publicKey)
    await this.base.append(this._op('invite', { id, role, requiresConfirmation: !!requiresConfirmation }))
    return { id, token: hex(kp.secretKey) }
  }

  async uninvite (id) { await this.base.append(this._op('uninvite', { id: String(id).toLowerCase() })) }

  /** Join with an invitation token — appended by THIS node's own core, optimistically. */
  async join (token, cred) {
    const sk = b4a.from(String(token), 'hex')
    if (sk.length !== 64) throw new Error('an invitation token is 64 bytes of hex')
    const op = { t: 'join', v: OP_VERSION, invite: hex(sk.subarray(32)), bound: this._bind(cred), at: Date.now() }
    op.sig = hex(crypto.sign(canonicalOpBytes(op), sk))
    await this.base.append(enc(op), { optimistic: true })
  }

  async acceptJoin (writer) { await this.base.append(this._op('accept-join', { writer: String(writer).toLowerCase() })) }
  async rejectJoin (writer) { await this.base.append(this._op('reject-join', { writer: String(writer).toLowerCase() })) }

  /** Attach this node's core as another device of a member already in the room (no invitation). */
  async addDevice (cred) { await this.base.append(this._op('add-device', { bound: this._bind(cred) }), { optimistic: true }) }
  async removeDevice (writer) { await this.base.append(this._op('remove-device', { writer: String(writer).toLowerCase() })) }

  async setRole (root, role) { await this.base.append(this._op('set-role', { member: String(root).toLowerCase(), role })) }
  async setStatus (root, { muted, banned } = {}) { await this.base.append(this._op('set-status', { member: String(root).toLowerCase(), muted, banned })) }
  async removeMember (root) { await this.base.append(this._op('remove-member', { member: String(root).toLowerCase() })) }

  async post (body, cred, name = recordName()) {
    await this.base.append(this._op('message', { name, body: String(body || '') }))
    return name
  }

  async deleteMessage (name) { await this.base.append(this._op('delete-message', { name })) }
  async flag (target, reason = '') { await this.base.append(this._op('flag', { target: String(target), reason: String(reason) })) }

  async setSetting (key, value) { await this.base.append(this._op('settings', { key, value })) }
  /** v1 name, kept for callers. */
  async setConfig (key, value) { return this.setSetting(key, value) }

  async listRoom (roomKey, note) { await this.base.append(this._op('list-room', { listed: String(roomKey).toLowerCase(), note: String(note || '') })) }
  async delistRoom (roomKey) { await this.base.append(this._op('delist-room', { listed: String(roomKey).toLowerCase() })) }

  // ── reads ────────────────────────────────────────────────────────────────────────────────────

  async founder () {
    const f = await this._get(FOUNDED)
    return f ? { root: f.member } : null
  }

  async settings () { return (await this._get(SETTINGS)) || {} }
  /** v1 name, kept for callers. */
  async config () { return this.settings() }

  /** Active members (left and removed are not members). */
  async members () {
    return (await this._prefix(MEMBER)).filter((e) => e.value.isActive && !e.value.isRemoved)
      .map((e) => ({ root: e.key, role: e.value.role, muted: !!e.value.isMuted, banned: !!e.value.isBanned }))
  }

  async member (root) { return this._get(memberKey(String(root).toLowerCase())) }

  async roleOf (root) {
    const m = await this.member(root)
    return m && m.isActive && !m.isRemoved ? m.role : null
  }

  async devices (root) {
    return (await this._prefix(DEVICE))
      .filter((e) => e.value.isActive && (!root || e.value.member === String(root).toLowerCase()))
      .map((e) => ({ writer: e.key, root: e.value.member, device: e.value.device }))
  }

  async invites () {
    return (await this._prefix(INVITE)).map((e) => ({ id: e.key, ...e.value }))
  }

  /** Pending join requests (invitations that require confirmation). */
  async requests () {
    return (await this._prefix(REQUEST)).filter((e) => !e.value.done)
      .map((e) => ({ writer: e.key, root: e.value.member, device: e.value.device, invite: e.value.invite }))
  }

  async listings () {
    return (await this._prefix(LISTING)).filter((e) => !e.value.removed)
      .map((e) => ({ room: e.key, note: e.value.note, by: e.value.by }))
  }

  async flags (target = null) {
    return (await this._prefix(FLAG)).map((e) => {
      const i = e.key.lastIndexOf(SEP)
      return { target: e.key.slice(0, i), by: e.key.slice(i + 1), ...e.value }
    }).filter((f) => !target || f.target === target)
  }

  async messages () {
    return (await this._prefix(MSG)).map((e) => ({ name: e.key, ...e.value }))
      .sort((a, b) => (a.at - b.at) || (a.name < b.name ? -1 : 1))
  }

  async _get (key) {
    const node = await this.base.view.get(b4a.from(key, 'utf8'))
    return node ? dec(node.value) : null
  }

  async _prefix (prefix) {
    const out = []
    const start = prefix + SEP
    for await (const node of this.base.view.createReadStream({
      gte: b4a.from(start, 'utf8'), lt: b4a.from(start + '\xff', 'utf8')
    })) {
      const value = dec(node.value)
      if (value == null) continue
      out.push({ key: b4a.toString(node.key, 'utf8').slice(start.length), value })
    }
    return out.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
  }
}

export default Room