OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-rooms / lib / room.js

529 lines · javascript ref:
/**
 * 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}`)
      }
    }
  }
}