/**
* Room — multiwriter substrate built on Autobase + Hyperbee view.
*
* Extracted from the duplicated patterns in packages/hyper-issues and
* packages/hyper-orgs. Both modules had nearly identical Autobase setup,
* Hyperbee view, and writer-management apply logic — the only difference
* was their domain-specific ops.
*
* `Room` handles:
* - Autobase + Hyperbee view boilerplate
* - Generic writer-management ops (add-writer, remove-writer)
* - Op encode/decode delegation to a pluggable schema
* - Single-writer or multi-writer (Autobase handles both)
*
* Schemas plug in by providing:
* - encodeOp(op) → Buffer
* - decodeOp(buf) → op object
* - validateOp(op) → throws on invalid
* - apply(batch, op, host) → mutates the Hyperbee batch
*
* The schema's apply is called for every NON-writer op. Writer ops are
* handled by Room itself.
*
* Usage:
*
* import { Room } from 'hyper-rooms'
*
* const room = new Room(corestore, {
* schema: myIssuesSchema,
* namespace: 'project-x',
* bootstrapKey: existingKey || null // null = create new room
* })
* await room.ready()
*
* // Append a domain op (validated by schema, applied to view)
* await room.append({ type: 'create-issue', id: 1, title: 'Bug' })
*
* // Read the view directly
* const issue = await room.view.get('issue:1')
*
* // Add a writer (other process holds writerKey from their local hypercore)
* await room.addWriter('alice', { writerKey: aliceLocalKeyHex, role: 'writer' })
*
* // Replicate via Hyperswarm
* swarm.on('connection', (conn) => room.corestore.replicate(conn))
*/
import Autobase from 'autobase'
import Hyperbee from 'hyperbee'
import Hypercore from 'hypercore'
import ReadyResource from 'ready-resource'
import b4a from 'b4a'
import crypto from 'hypercore-crypto'
/**
* Sign a writer-rotation vouch: the voucher (an existing writer) signs the NEW
* writer key, authorizing its admission via the doorbell (see Room.requestWriter
* / _applyRequestWriter). Returns the signature hex.
* @param {string} newWriterKeyHex - the new local writer key being vouched for
* @param {Buffer} voucherSecretKey - the existing writer's hypercore secret key
*/
export function signWriterVouch (newWriterKeyHex, voucherSecretKey) {
return b4a.toString(crypto.sign(b4a.from(newWriterKeyHex, 'hex'), voucherSecretKey), 'hex')
}
export class Room extends ReadyResource {
/**
* @param {Corestore} corestore - parent corestore (will be namespaced)
* @param {object} opts
* @param {object} opts.schema - { encodeOp, decodeOp, validateOp, apply }
* @param {string} [opts.namespace='room'] - corestore namespace for isolation
* @param {Buffer|string|null} [opts.bootstrapKey=null] - existing room key, null = create new
* @param {string} [opts.viewName='view'] - hyperbee core name within the namespace
*/
constructor (corestore, opts = {}) {
super()
if (!opts.schema) throw new Error('Room requires a schema')
this._validateSchema(opts.schema)
this._schema = opts.schema
this._namespace = opts.namespace || 'room'
this._viewName = opts.viewName || 'view'
this._corestore = corestore.namespace(this._namespace)
this._bootstrapKey = this._normalizeKey(opts.bootstrapKey)
// Canonical multi-writer Autobase config — see hyper-identity/identity-registry.js
// for full rationale. optimistic+ackInterval+ackThreshold = keet pattern.
this._base = new Autobase(this._corestore, this._bootstrapKey, {
open: (store) => {
const core = store.get(this._viewName, { valueEncoding: 'json' })
return new Hyperbee(core, {
keyEncoding: 'utf-8',
valueEncoding: 'json'
})
},
apply: this._apply.bind(this),
valueEncoding: 'binary',
optimistic: true,
ackInterval: 1000,
ackThreshold: 4
})
// Resilience (pear-git #3): autobase routes any drain/apply failure through
// _onError → emit('error'). With NO 'error' listener, EventEmitter re-throws,
// so ONE broken tracker SIGABRTs the whole daemon — crash-looping and taking
// every other repo down. A standalone peer hits this when its stale local
// state can't reconcile a manifest migration that happened in the group,
// whereas full-replica mirrors never do (they take the finished picture).
// Attach handlers so a broken tracker is ISOLATED + observable instead of
// fatal — matching keet's room-autobase 'error'/'warning' handlers. Consumers
// react to the re-emitted 'base-error' (Increment 2: fresh-namespace re-sync).
// Custom event names never throw when unlistened, so re-emitting is safe.
this._baseError = null
this._base.on('error', (err) => {
this._baseError = err
try {
// eslint-disable-next-line no-console
console.error('[hyper-rooms] tracker autobase error (ns=' + this._namespace + '): ' + (err && err.message ? err.message : err))
} catch {}
this.emit('base-error', err)
})
this._base.on('warning', (err) => {
try {
// eslint-disable-next-line no-console
console.error('[hyper-rooms] tracker autobase warning (ns=' + this._namespace + '): ' + (err && err.message ? err.message : err))
} catch {}
this.emit('base-warning', err)
})
}
// ── Lifecycle ────────────────────────────────────────────────
async _open () {
await this._base.ready()
}
async _close () {
await this._base.close()
}
// ── Properties ───────────────────────────────────────────────
/** Bootstrap key (Autobase root). Share this to invite mirrors / readers. */
get key () { return this._base.key }
get keyHex () { return this._base.key ? b4a.toString(this._base.key, 'hex') : null }
get discoveryKey () { return this._base.discoveryKey }
/** Local writer key (this peer's Hypercore). Share THIS for addWriter on the inviter. */
get localKey () { return this._base.local ? this._base.local.key : null }
get localKeyHex () { return this._base.local ? b4a.toString(this._base.local.key, 'hex') : null }
/** True if this peer can append ops. */
get writable () { return this._base.writable }
/**
* The autobase error that isolated this room, if any (resilience, #3).
* Non-null once the tracker's autobase has emitted 'error' (e.g. a stale
* local state that couldn't reconcile a group manifest migration). A consumer
* treats a non-null value as "this tracker is broken — re-sync it fresh".
*/
get errored () { return this._baseError }
/** Read-only access to the Hyperbee view. */
get view () { return this._base.view }
/** The corestore (namespaced). For replication via swarm. */
get corestore () { return this._corestore }
/** Underlying Autobase (escape hatch). */
get base () { return this._base }
// ── Op append (writer side) ──────────────────────────────────
/**
* Append a domain op to the room. The schema validates and encodes it.
* Writer ops (add-writer / remove-writer) should NOT be appended via this
* method — use addWriter() / removeWriter() instead.
*
* @param {object} op - the op object (will be validated by the schema)
*/
async append (op) {
if (op && (op.type === 'add-writer' || op.type === 'remove-writer')) {
throw new Error('Use addWriter() / removeWriter() for writer ops')
}
this._schema.validateOp(op)
const encoded = this._schema.encodeOp(op)
await this._base.append(encoded)
}
// ── Writer management ────────────────────────────────────────
/**
* Append an add-writer op. The new peer must already have its OWN local
* Hypercore in some other corestore — pass the hex key of that local core.
*
* Roles:
* - 'owner' → indexer = true (full Autobase indexer)
* - 'writer' → indexer = false (can append, doesn't index)
* - 'mirror' → metadata only, NOT registered as a writer (read-only mirror)
*
* @param {string} userId - opaque identifier for the writer
* @param {object} opts
* @param {string} opts.writerKey - hex of the writer's local Hypercore key
* @param {'owner'|'writer'|'mirror'} [opts.role='writer']
* @param {string} [opts.addedBy] - who added this writer (for audit trail)
*/
async addWriter (userId, opts = {}) {
const { writerKey, role = 'writer', addedBy = null } = opts
if (!writerKey) throw new Error('addWriter requires opts.writerKey (hex)')
if (!['owner', 'writer', 'mirror'].includes(role)) {
throw new Error('role must be owner, writer, or mirror')
}
const op = {
type: 'add-writer',
userId,
writerKey,
role,
addedBy,
timestamp: Date.now()
}
await this._base.append(this._schema.encodeOp(op))
}
/**
* DOORBELL: a not-yet-admitted peer requests admission of its own new writer
* key, proving authorization by a VOUCH from an existing writer key (keet's
* proof-gated-optimistic admission, using the writer set as the authorization
* record). Used when an existing writer ROTATES its key (e.g. forceResync
* re-join into a fresh corestore): the peer signs its NEW key with its OLD
* (still-admitted) writer key. apply (_applyRequestWriter) admits ONLY if the
* vouch verifies AND the voucher is a current writer — and only ever as a
* PLAIN writer (never indexer/owner: no self-escalation).
*
* Appended OPTIMISTICALLY so a not-yet-writable peer can ring it; it carries
* only the proof (no content), so nothing is lost if no indexer admits it.
*
* autobase writer keys are MANIFEST keys, not raw signing keys, so the vouch
* carries the voucher's raw SIGNER publicKey; apply recomputes the manifest
* key from it (`Hypercore.key({signers:[{publicKey}]})`) to bind it back to a
* current writer.
*
* @param {object} opts
* @param {string} opts.newWriterKey - this peer's new local writer key (hex)
* @param {string} opts.voucherSignerKey - the voucher writer's raw signer publicKey (hex);
* from `oldRoom.base.local.keyPair.publicKey`
* @param {string} opts.voucherSig - signWriterVouch(newWriterKey, voucherSignerSecretKey)
* @param {string} [opts.userId] - writer-set label (default = newWriterKey)
*/
async requestWriter (opts = {}) {
const { newWriterKey, voucherSignerKey, voucherSig, userId } = opts
if (!newWriterKey) throw new Error('requestWriter requires newWriterKey (hex)')
if (!voucherSignerKey || !voucherSig) throw new Error('requestWriter requires voucherSignerKey + voucherSig')
const op = {
type: 'request-writer',
userId: userId || newWriterKey,
newWriterKey,
voucherSignerKey,
voucherSig,
timestamp: Date.now()
}
await this._base.append(this._schema.encodeOp(op), { optimistic: true })
}
/**
* Append a remove-writer op for the given userId.
* @param {string} userId
*/
async removeWriter (userId) {
const op = {
type: 'remove-writer',
userId,
timestamp: Date.now()
}
await this._base.append(this._schema.encodeOp(op))
}
/**
* List all writers in the view.
* @returns {Promise<Array<{ userId, role, writerKey, addedBy, addedAt }>>}
*/
async listWriters () {
const out = []
for await (const entry of this._base.view.createReadStream({
gte: 'writer:',
lt: 'writer:\xff'
})) {
out.push({
userId: entry.key.slice('writer:'.length),
...entry.value
})
}
return out
}
/**
* Get a specific writer by userId.
*/
async getWriter (userId) {
const entry = await this._base.view.get(`writer:${userId}`)
return entry ? entry.value : null
}
// ── Apply function (called by Autobase) ──────────────────────
async _apply (nodes, view, host) {
const batch = view.batch()
for (const node of nodes) {
let op
try {
op = this._schema.decodeOp(node.value)
} catch (err) {
// Invalid op — skip silently to keep apply deterministic
continue
}
// Generic writer ops. `node` is passed through so the apply can see WHO
// authored the op — authorization for writer-set changes lives here, not
// at the RPC that happened to produce them (org #15).
if (op.type === 'add-writer') {
await this._applyAddWriter(batch, op, host, node)
continue
}
if (op.type === 'remove-writer') {
await this._applyRemoveWriter(batch, op, host, node)
continue
}
if (op.type === 'request-writer') {
await this._applyRequestWriter(view, batch, op, host)
continue
}
// Schema-specific ops
try {
await this._schema.apply(batch, op, host)
} catch (err) {
// Don't throw inside apply — log and continue.
// (apply must be deterministic; an exception would corrupt state.)
// eslint-disable-next-line no-console
console.error('[Room] schema.apply error:', err.message)
}
}
await batch.flush()
}
/**
* Is the op's author a current INDEXER of this room? (org #15)
*
* Autobase hands every apply node a `from` — the authoring writer's core —
* so the writer set can be authorized from inside apply, which is the only
* place that works: apply runs on ops that arrive by REPLICATION, not just
* on ops that came through some RPC, so a gate on the RPC (pear-git-v3's
* requireAdmin on repo-add-writer) cannot bind an op appended directly to
* the base by an existing writer.
*
* Indexers are autobase's own trust root — the quorum that signs the
* linearized view — so "author is an indexer" is the same authority the
* room already relies on, not a new concept layered on top.
*
* Genesis: before any indexer is recorded, the room's creator is the only
* possible author and there is nobody to authorize against, so allow.
*/
_authorIsIndexer (host, node) {
const author = node && node.from && node.from.key
if (!author) return false
const system = host && host.system
const indexers = system && Array.isArray(system.indexers) ? system.indexers : []
if (indexers.length === 0) return true
for (const idx of indexers) {
if (idx && idx.key && b4a.equals(idx.key, author)) return true
}
return false
}
// Refusals are LOGGED, never silent. A writer-set change that stops applying
// is otherwise indistinguishable from replication lag, and this gate is new
// behaviour on a substrate that hyper-issues and hyper-orgs both build on.
_refuseWriterOp (kind, op, node) {
const author = node && node.from && node.from.key
// eslint-disable-next-line no-console
console.error('[hyper-rooms] refused ' + kind + ' (ns=' + this._namespace + '): author ' +
(author ? b4a.toString(author, 'hex').slice(0, 16) : 'unknown') +
' is not a current indexer; userId=' + (op && op.userId))
}
/**
* Apply an add-writer op. (org #15)
*
* This used to honour ANY add-writer op that appeared in the linearized log,
* with no check on who authored it — and `indexer: op.role === 'owner'` took
* the role straight from the op. So an existing plain writer could append
* `{type:'add-writer', role:'owner'}` for its own key and promote ITSELF to
* indexer, bypassing the requireAdmin on the RPC entirely.
*
* That was worse than a permissions issue: changing the indexer set migrates
* the autobase manifest, which is precisely what poisons standalone-peer
* trackers (pear-git #3), so it doubled as a denial of service on the room.
*
* Now the author must be a current indexer. Note this leaves promotion in
* the hands of existing indexers — which is what `pgh org set-auth --level
* owner` and `pgh repo grant-writer` already are — while removing the
* self-service path a plain writer had.
*/
async _applyAddWriter (batch, op, host, node) {
if (!this._authorIsIndexer(host, node)) {
this._refuseWriterOp('add-writer', op, node)
return
}
await batch.put(`writer:${op.userId}`, {
role: op.role,
writerKey: op.writerKey || null,
addedBy: op.addedBy || null,
addedAt: op.timestamp || Date.now()
})
if (op.writerKey && op.role !== 'mirror') {
try {
await host.addWriter(b4a.from(op.writerKey, 'hex'), {
indexer: op.role === 'owner'
})
} catch (err) {
// eslint-disable-next-line no-console
console.error('[Room] host.addWriter failed:', err.message)
}
}
}
// DOORBELL apply (see requestWriter). Deterministic, proof-gated: admit
// op.newWriterKey ONLY if (1) op.voucherSig is a valid signature of
// op.newWriterKey by op.voucherKey, AND (2) op.voucherKey is a CURRENT
// non-mirror writer in this room. Authorizes a key ROTATION by an existing
// writer; grants a PLAIN writer only (never indexer). Silently no-ops on a
// bad proof — a forged/unauthorized request admits nothing.
async _applyRequestWriter (view, batch, op, host) {
const { newWriterKey, voucherSignerKey, voucherSig } = op
if (!newWriterKey || !voucherSignerKey || !voucherSig) return
// (1) verify the vouch signature over the new key by the raw signer key
let sigOk = false
try {
sigOk = crypto.verify(
b4a.from(newWriterKey, 'hex'),
b4a.from(voucherSig, 'hex'),
b4a.from(voucherSignerKey, 'hex')
)
} catch { sigOk = false }
if (!sigOk) return
// (2) bind the signer to a writer key: recompute the default single-signer
// MANIFEST key from the signer publicKey (autobase writer keys are manifests,
// not raw keys). A non-default manifest simply won't match → safe rejection.
let voucherKey
try {
voucherKey = b4a.toString(Hypercore.key({ signers: [{ publicKey: b4a.from(voucherSignerKey, 'hex') }] }), 'hex')
} catch { return }
// (3) that manifest key must be a current, non-mirror writer in this room
let voucherIsWriter = false
try {
for await (const entry of view.createReadStream({ gte: 'writer:', lt: 'writer:\xff' })) {
const v = entry.value
if (v && v.writerKey === voucherKey && v.role !== 'mirror') { voucherIsWriter = true; break }
}
} catch { voucherIsWriter = false }
if (!voucherIsWriter) return
// Admit — PLAIN writer only (no self-escalation to indexer/owner).
const userId = op.userId || newWriterKey
await batch.put(`writer:${userId}`, {
role: 'writer',
writerKey: newWriterKey,
addedBy: 'doorbell:' + voucherKey.slice(0, 16),
addedAt: op.timestamp || 0
})
try {
await host.addWriter(b4a.from(newWriterKey, 'hex'), { indexer: false })
} catch (err) {
// eslint-disable-next-line no-console
console.error('[Room] doorbell addWriter failed:', err.message)
}
}
// Same authorization as add-writer (org #15). Not named in the issue, but it
// is the same defect on the same writer set: ungated, any writer could evict
// any other writer — including an indexer — by appending one op. Gating only
// the add side would have left the room removable by a plain writer.
async _applyRemoveWriter (batch, op, host, node) {
if (!this._authorIsIndexer(host, node)) {
this._refuseWriterOp('remove-writer', op, node)
return
}
const existing = await batch.get(`writer:${op.userId}`)
if (!existing) return
await batch.del(`writer:${op.userId}`)
if (existing.value.writerKey) {
try {
await host.removeWriter(b4a.from(existing.value.writerKey, 'hex'))
} catch (err) {
// eslint-disable-next-line no-console
console.error('[Room] host.removeWriter failed:', err.message)
}
}
}
// ── Helpers ──────────────────────────────────────────────────
_normalizeKey (key) {
if (!key) return null
return typeof key === 'string' ? b4a.from(key, 'hex') : key
}
_validateSchema (schema) {
const required = ['encodeOp', 'decodeOp', 'validateOp', 'apply']
for (const fn of required) {
if (typeof schema[fn] !== 'function') {
throw new Error(`Schema missing required function: ${fn}`)
}
}
}
}