OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / backend / browser-state-sync.cjs

106 lines · javascript ref:
// Encrypted multi-device browser-state sync manager (EXPERIMENTAL, Rollout
// Phase 4). An Autobase op-log of the user's OWN bookmark edits, ENCRYPTED so
// only the user's paired devices (which hold the encryption key) can read it.
// CommonJS so Bare can require() it and Node can default-import it for the
// smoke. Same op-log architecture as autobee-catalog-manager: Autobase owns
// ordering + replication; the PURE reducer (browser-state-apply) materializes
// the bookmark set. No wall-clock.
//
// Pairing model (your own devices): one device creates the sync base and gets
// an invite = { key (bootstrap), encryptionKey, writerKey }. Another device
// opens with the same key + encryptionKey, hands over ITS writerKey, and the
// first device addWriter()s it. Encryption key + writer invite are sensitive
// capabilities — they ARE the keys to your synced data.

const Autobase = require('autobase')
const Hyperbee = require('hyperbee')
const {
  addBookmarkOp, removeBookmarkOp, addWriterOp, validateOp, OP_ADD_WRITER
} = require('./browser-state-ops.cjs')
const { applyView, toStateData } = require('./browser-state-apply.cjs')

function toBuf (v) {
  if (v == null) return null
  return typeof v === 'string' ? Buffer.from(v, 'hex') : v
}

class BrowserStateSync {
  constructor (store, opts = {}) {
    this.store = store
    this.bootstrap = toBuf(opts.bootstrap)
    this.encryptionKey = toBuf(opts.encryptionKey)
    this._ns = opts.namespace ||
      (this.bootstrap ? Buffer.from(this.bootstrap).toString('hex') : 'local')
    this.base = null
  }

  async ready () {
    const ns = this._ns
    const handlers = {
      valueEncoding: 'json',
      open: (store) => new Hyperbee(store.get({ name: `browser-state-${ns}-view` }), {
        extension: false, keyEncoding: 'utf-8', valueEncoding: 'json'
      }),
      apply: async (nodes, view, host) => {
        const head = await view.get('meta!count').catch(() => null)
        let count = head && Number.isFinite(head.value) ? head.value : 0
        for (const node of nodes) {
          const op = node.value
          if (op && op.type === OP_ADD_WRITER && /^[0-9a-f]{64}$/i.test(op.key || '')) {
            await host.addWriter(Buffer.from(op.key, 'hex'), { indexer: true })
            continue
          }
          const verdict = validateOp(op)
          if (!verdict.ok && !verdict.retain) continue
          await view.put('op!' + String(count).padStart(12, '0'), op)
          count++
        }
        await view.put('meta!count', count)
      }
    }
    if (this.encryptionKey) handlers.encryptionKey = this.encryptionKey
    // CRITICAL: give the Autobase its OWN namespaced substore rather than the raw
    // store. base.close() runs store.close(); on the shared ROOT Corestore that
    // tears down Hyperdrive/UserData/Names/replication for the WHOLE app (a single
    // consumer's close kills everything — verified by the shared-store regression
    // test). A namespace session's close() frees only its own cores, leaving the
    // root alive. The namespace MUST be a FIXED string (there is exactly ONE sync
    // base per store), NOT keyed by `_ns`: the CMD_SYNC_CREATE mint runs with
    // bootstrap=null and reopen-by-key runs with `_ns`=key, so an `_ns`-keyed
    // substore would split mint and reopen apart. A fixed substore keeps the mint's
    // writer core where reopen-by-key looks (mirrors N5's ensureNameRegistry).
    const baseStore = typeof this.store.namespace === 'function'
      ? this.store.namespace('bss-browser-state') : this.store
    this.base = new Autobase(baseStore, this.bootstrap, handlers)
    await this.base.ready()
    return this
  }

  get writable () { return !!(this.base && this.base.writable) }
  get key () { return this.base && this.base.key ? Buffer.from(this.base.key).toString('hex') : '' }
  get discoveryKey () { return this.base && this.base.discoveryKey ? Buffer.from(this.base.discoveryKey) : null }
  get localKey () { return this.base && this.base.local && this.base.local.key ? Buffer.from(this.base.local.key).toString('hex') : '' }

  async addBookmark (bm) { await this.base.append(addBookmarkOp(bm)) }
  async removeBookmark (url) { await this.base.append(removeBookmarkOp(url)) }
  async addWriter (writerKeyHex) { await this.base.append(addWriterOp(writerKeyHex)) }
  async update () { await this.base.update() }

  // Materialize the bookmark set from the linearized op log via the pure
  // reducer — the single source of truth for conflict resolution.
  async state () {
    await this.base.update()
    const view = this.base.view
    const ops = []
    for await (const entry of view.createReadStream({ gte: 'op!', lt: 'op!~' })) {
      ops.push(entry.value)
    }
    const data = toStateData(applyView(ops))
    return { ...data, writable: this.writable, key: this.key, writerKey: this.localKey }
  }

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

module.exports = { BrowserStateSync }