OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / backend / index-room-client.js

209 lines · javascript ref:
/**
 * Index Room Client — read-only consumer of a HiveRelay index room (Phase 5).
 *
 * The relay's index sidecar (services/index-sidecar) publishes a schema-sheets
 * room with four schemas — relay-directory, app-manifest, verification,
 * pin-registry — advertised as `indexRoom` (z32) in the relay's capability doc
 * and /catalog.json. This client blind-replicates that room read-only (no
 * encryption key — it's public by design) and queries it, mirroring
 * SheetsCatalog and reusing its Bare-bundled schema-sheets + query chokepoint.
 *
 * Trust: the room is an INDEX, not an authority. relay-directory rows carry the
 * full signed capability `doc` (+ `capabilitySig`); when a verifier is supplied
 * we re-verify each row against its own doc and drop the rest. (Design Risk #5.)
 */
const z32 = require('z32')
const b4a = require('b4a')
const { decodeSheetsLink, safeJmesPath, MAX_SHEETS_ROWS } = require('./sheets-catalog')
const { normalizeCatalogApp } = require('./catalog-safety.cjs')

// Same Bare-bundled schema-sheets the catalogue uses — the index room is the
// same corestore-7/hypercore-11 generation, so replication is direct.
let _SchemaSheets = null
function loadSchemaSheets () {
  if (!_SchemaSheets) {
    const m = require('./sheets-bundle.cjs')
    _SchemaSheets = m.default || m
  }
  return _SchemaSheets
}

const INDEX_SCHEMAS = ['relay-directory', 'app-manifest', 'verification', 'pin-registry', 'nostr-event']

// Lazy-loaded NIP-01 verifier (pulls in the secp256k1 bundle) — only when nostr
// events are actually read, so the catalogue path stays free of nostr crypto.
let _verifyEvent = null
function loadVerifyEvent () {
  if (!_verifyEvent) _verifyEvent = require('./nostr-events-apply.cjs').verifyEvent
  return _verifyEvent
}

// nostr-event row → the NIP-01 event. The event may be the row json directly
// (flat) or nested under `.event`; returns null for a corrupt row.
function nostrRowToEvent (row) {
  if (!row || typeof row.json !== 'object' || row.json === null) return null
  const j = row.json
  const ev = (j.event && typeof j.event === 'object') ? j.event : j
  return (ev && typeof ev === 'object') ? ev : null
}

// nostr-event rows → verified NIP-01 events. Re-verify EVERY event (well-formed +
// id-commits-to-content + BIP-340 schnorr) and DROP the rest — the same verify-
// and-drop discipline as listRelayDirectory: a relay/peer is an index, never an
// authority, so it can only serve events, never forge them. The trust-frontier
// gate (is the author attested by a verified contact?) is a SEPARATE concern,
// applied by the consumer via nostr-ingest.partitionByTrust.
function verifyNostrRows (rows) {
  const verifyEvent = loadVerifyEvent()
  const events = []
  const dropped = []
  for (const r of rows || []) {
    const ev = nostrRowToEvent(r)
    if (!ev) { dropped.push({ reason: 'malformed row' }); continue }
    const v = verifyEvent(ev)
    if (v.ok) events.push(ev)
    else dropped.push({ id: ev.id, reason: v.reason })
  }
  return { events, dropped }
}

// A hiveindex:// link wraps the same z32 a sheets link does (key32 public —
// the index room never ships an encryption key). Strip the scheme, then reuse
// the validated sheets decoder.
function decodeIndexLink (link) {
  const s = String(link == null ? '' : link).trim().replace(/^hiveindex:\/\//i, '')
  return decodeSheetsLink(s)
}

// app-manifest row -> the in-memory app DTO CatalogManager aggregates (mirror
// of sheets-catalog rowToApp). Returns null for a corrupt row.
function manifestRowToApp (row) {
  if (!row || typeof row.json !== 'object' || row.json === null) return null
  const j = row.json
  return normalizeCatalogApp({
    id: j.appId || row.uuid,
    name: j.name,
    description: null,
    driveKey: j.driveKey || null,
    link: j.link || null,
    type: j.type || (j.driveKey && !j.link ? 'standalone' : 'hypersite'),
    author: j.author || null,
    categories: Array.isArray(j.categories) ? j.categories : [],
    version: j.version || null,
    icon: j.icon || null,
    verification: 'relay-listed',
    publisherKey: j.publisherPubkey || null
  }, { source: 'hiveindex' })
}

class IndexRoomClient {
  constructor (store, swarm, opts = {}) {
    this.store = store
    this.swarm = swarm
    this.verify = typeof opts.verify === 'function' ? opts.verify : null
    this.sheets = null
    this._schemaIdsP = null
  }

  // Open the relay's index room read-only by z32 (or hiveindex://) link.
  async open (link) {
    const SchemaSheets = await loadSchemaSheets()
    const { key, encryptionKey } = decodeIndexLink(link)
    const opts = encryptionKey ? { encryptionkey: encryptionKey } : {}
    const sheets = new SchemaSheets(this.store, key, opts)
    await sheets.ready()
    const dk = sheets.base && sheets.base.discoveryKey
    if (this.swarm && dk) {
      try { this.swarm.join(dk, { server: false, client: true }) } catch {}
    }
    this.sheets = sheets
    return sheets
  }

  link () { return z32.encode(this.sheets.key) }
  keyHex () { return b4a.toString(this.sheets.key, 'hex') }

  // name -> schemaId, cached as a shared promise (no racing listSchemas scans).
  async _schemaIds () {
    if (this._schemaIdsP) return this._schemaIdsP
    this._schemaIdsP = (async () => {
      const schemas = await this.sheets.listSchemas()
      const map = {}
      for (const s of schemas) map[s.name] = s.schemaId
      return map
    })()
    return this._schemaIdsP
  }

  async _list (schemaName, jmespath) {
    const q = safeJmesPath(jmespath)
    const ids = await this._schemaIds()
    const sid = ids[schemaName]
    if (!sid) return []
    const opts = { limit: MAX_SHEETS_ROWS }
    if (q) opts.query = q
    let rows
    try {
      rows = await this.sheets.list(sid, opts)
    } catch (err) {
      if (q) throw new Error('Invalid search query')
      throw err
    }
    return rows.filter(Boolean).slice(0, MAX_SHEETS_ROWS)
  }

  // relay-directory rows → the discoverable relay list. Re-verifies each row
  // against its own signed doc when a verifier was supplied (rows that fail are
  // dropped); `verified` is null when no verifier is wired.
  async listRelayDirectory (jmespath) {
    const rows = await this._list('relay-directory', jmespath)
    const out = []
    for (const r of rows) {
      const j = r.json || {}
      let verified = null
      if (this.verify) {
        try { verified = !!(j.doc && this.verify(j.doc)) } catch { verified = false }
        if (!verified) continue
      }
      out.push({
        pubkey: j.pubkey || null,
        gatewayUrl: j.gatewayUrl || null,
        region: j.region || null,
        features: Array.isArray(j.features) ? j.features : [],
        health: j.health || null,
        verified,
        row: r
      })
    }
    return out
  }

  // app-manifest rows → app DTOs (the catalogue read path, hiveindex:// source).
  async listApps (jmespath) {
    const rows = await this._list('app-manifest', jmespath)
    return rows.map(manifestRowToApp).filter(Boolean).slice(0, MAX_SHEETS_ROWS)
  }

  // nostr-event rows → verified events (verify-and-drop). The caller applies the
  // trust-frontier gate (nostr-ingest.partitionByTrust) to split feed vs quarantine.
  async listNostrEvents (jmespath) {
    const rows = await this._list('nostr-event', jmespath)
    return verifyNostrRows(rows).events
  }

  async listVerifications (jmespath) {
    const rows = await this._list('verification', jmespath)
    return rows.map(r => r.json).filter(Boolean)
  }

  async listPins (jmespath) {
    const rows = await this._list('pin-registry', jmespath)
    return rows.map(r => r.json).filter(Boolean)
  }

  async update () { if (this.sheets && this.sheets.base) await this.sheets.base.update() }
  async close () { try { if (this.sheets) await this.sheets.close() } catch {} }
}

module.exports = { IndexRoomClient, INDEX_SCHEMAS, manifestRowToApp, decodeIndexLink, nostrRowToEvent, verifyNostrRows }