// 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