OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / backend / pear-bridge.js

973 lines · javascript ref:
/**
 * Pear Bridge — P2P API for apps running in PearBrowser's WebView
 *
 * Provides a JavaScript API that gets injected into WebViews,
 * allowing P2P apps to access Hyperswarm, Autobase, and Hyperdrive
 * through the worklet backend.
 *
 * Apps call window.pear.* which posts messages to React Native,
 * which forwards them via RPC to the worklet, which does the P2P work.
 *
 * Architecture:
 *   WebView JS → postMessage → React Native → RPC → Worklet → Hyperswarm/Autobase
 */

const Autobase = require('autobase')
const ApplyState = require('autobase/lib/apply-state.js')
const Hyperbee = require('hyperbee')
const crypto = require('bare-crypto')

installAutobaseTraceGuard()

function installAutobaseTraceGuard () {
  if (!ApplyState || !ApplyState.prototype || ApplyState.prototype._pearBridgeTraceGuarded) return

  const viewsFor = (state) => Array.isArray(state && state.views) ? state.views : []
  const start = (ref) => {
    if (ref && ref.tracer && typeof ref.tracer.start === 'function') ref.tracer.start()
  }
  const end = (ref) => {
    if (!ref || !ref.tracer || typeof ref.tracer.end !== 'function') return []
    const blocks = ref.tracer.end()
    return Array.isArray(blocks) ? blocks : []
  }

  ApplyState.prototype._startTrace = function () {
    start(this && this.systemRef)
    start(this && this.encryptionView && this.encryptionView.ref)
    const views = viewsFor(this)
    for (let i = 0; i < views.length; i++) start(views[i] && views[i].ref)
  }

  ApplyState.prototype._endTrace = function (node) {
    const trace = {
      system: end(this && this.systemRef),
      encryption: end(this && this.encryptionView && this.encryptionView.ref),
      user: []
    }

    const views = viewsFor(this)
    for (let i = 0; i < views.length; i++) {
      const blocks = end(views[i] && views[i].ref)
      if (blocks.length) trace.user.push({ view: i, blocks })
    }

    if (node && (trace.system.length || trace.encryption.length || trace.user.length)) node.trace = trace
  }

  Object.defineProperty(ApplyState.prototype, '_pearBridgeTraceGuarded', {
    value: true,
    enumerable: false
  })
}

class PearBridge {
  constructor (store, swarm) {
    this.store = store
    this.swarm = swarm
    this._syncGroups = new Map() // groupId → { base, swarm topic }
    this._syncGroupOpens = new Map() // groupId → in-flight create/join promise
    this._appDrives = new Map() // appId → Hyperdrive
  }

  // Validate appId format to prevent path traversal
  _validateAppId (appId) {
    if (typeof appId !== 'string') {
      throw new Error('appId must be a string')
    }
    if (appId.length < 1 || appId.length > 64) {
      throw new Error('appId must be between 1 and 64 characters')
    }
    // Only allow alphanumeric, hyphens, and underscores
    if (!/^[a-zA-Z0-9_-]+$/.test(appId)) {
      throw new Error('appId contains invalid characters')
    }
    // Prevent special reserved names
    const reserved = ['__proto__', 'constructor', 'prototype']
    if (reserved.includes(appId.toLowerCase())) {
      throw new Error('appId is reserved')
    }
    return appId
  }

  // ---------------------------------------------------------------------------
  // Sync Groups (Autobase)
  // ---------------------------------------------------------------------------

  _syncGroupInfo (appId, group) {
    return {
      inviteKey: group.inviteKey,
      appId,
      writerPublicKey: group.base.local.key.toString('hex')
    }
  }

  async _openSyncGroupOnce (appId, open) {
    const existing = this._syncGroups.get(appId)
    if (existing) return this._syncGroupInfo(appId, existing)

    const pending = this._syncGroupOpens.get(appId)
    if (pending) return pending

    const promise = (async () => {
      try {
        const again = this._syncGroups.get(appId)
        if (again) return this._syncGroupInfo(appId, again)
        return await open()
      } finally {
        this._syncGroupOpens.delete(appId)
      }
    })()
    this._syncGroupOpens.set(appId, promise)
    return promise
  }

  /**
   * Create a new sync group — returns an invite key others can join with
   */
  async createSyncGroup (appId, applyFn) {
    // Validate appId
    this._validateAppId(appId)
    return this._openSyncGroupOnce(appId, async () => {
      const localWriter = this.store.get({ name: `pear-app-${appId}-writer` })
      await localWriter.ready()

      // CRITICAL: give each sync group its OWN namespaced substore (keyed by the
      // already-validated appId) 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 namespace session's close()
      // frees only that group's cores. A per-appId substore also stops two sync
      // groups from colliding on the same Autobase local writer core on one store.
      const baseStore = typeof this.store.namespace === 'function'
        ? this.store.namespace(`pear-bridge-${appId}`) : this.store
      const base = new Autobase(baseStore, null, {
        apply: applyFn || this._defaultApply,
        open: (store) => new Hyperbee(store.get({ name: `pear-app-${appId}-view` }), {
          keyEncoding: 'utf-8',
          valueEncoding: 'binary',
          extension: false
        }),
        valueEncoding: 'json'
      })

      await base.ready()
      const inviteKey = (base.key || base.local.key).toString('hex')
      const writerPublicKey = base.local.key.toString('hex')

      // Derive topic and join swarm
      const topic = crypto.createHash('sha256').update(inviteKey).digest()
      this.swarm.join(topic, { server: true, client: true })

      this._syncGroups.set(appId, { base, topic, inviteKey })

      return { inviteKey, appId, writerPublicKey }
    })
  }

  /**
   * Join an existing sync group by invite key
   */
  async joinSyncGroup (appId, inviteKeyHex, applyFn) {
    // Validate appId
    this._validateAppId(appId)
    // Validate invite key format
    if (!inviteKeyHex || !/^[0-9a-f]{64}$/i.test(inviteKeyHex)) {
      throw new Error('Invalid invite key format')
    }
    const existing = this._syncGroups.get(appId)
    if (existing) {
      if (existing.inviteKey !== inviteKeyHex) {
        throw new Error('Sync group already open with a different invite key')
      }
      return this._syncGroupInfo(appId, existing)
    }
    return this._openSyncGroupOnce(appId, async () => {
      const bootstrapKey = Buffer.from(inviteKeyHex, 'hex')

      // Per-appId substore — see createSyncGroup: keeps each group's cores out of
      // the raw root store so close() can't tear the whole app's storage down.
      const baseStore = typeof this.store.namespace === 'function'
        ? this.store.namespace(`pear-bridge-${appId}`) : this.store
      const base = new Autobase(baseStore, bootstrapKey, {
        apply: applyFn || this._defaultApply,
        open: (store) => new Hyperbee(store.get({ name: `pear-app-${appId}-view` }), {
          keyEncoding: 'utf-8',
          valueEncoding: 'binary',
          extension: false
        }),
        valueEncoding: 'json'
      })

      await base.ready()
      await base.view.update()

      const topic = crypto.createHash('sha256').update(inviteKeyHex).digest()
      this.swarm.join(topic, { server: true, client: true })

      this._syncGroups.set(appId, { base, topic, inviteKey: inviteKeyHex })

      const writerPublicKey = base.local.key.toString('hex')
      return { inviteKey: inviteKeyHex, appId, writerPublicKey }
    })
  }

  /**
   * Append an operation to a sync group
   */
  async append (appId, operation) {
    // Validate appId
    this._validateAppId(appId)
    const group = this._syncGroups.get(appId)
    if (!group) throw new Error('Sync group not found: ' + appId)

    const entry = {
      type: operation.type,
      data: operation.data,
      timestamp: operation.timestamp || new Date().toISOString(),
      deviceId: group.base.local.key.toString('hex').slice(0, 16)
    }

    await group.base.append(JSON.stringify(entry))
    await group.base.view.update()

    return { ok: true }
  }

  /**
   * Query the sync group's view (Hyperbee)
   */
  async get (appId, key) {
    // Validate appId
    this._validateAppId(appId)
    // Validate key
    if (typeof key !== 'string' || key.length === 0 || key.length > 1024) {
      throw new Error('Invalid key')
    }
    const group = this._syncGroups.get(appId)
    if (!group) throw new Error('Sync group not found: ' + appId)

    await group.base.view.update()
    const node = await group.base.view.get(key)
    if (!node || !node.value) return null
    return JSON.parse(node.value.toString())
  }

  /**
   * List entries from the sync group's view with a prefix
   */
  async list (appId, prefix, opts = {}) {
    // Validate appId
    this._validateAppId(appId)
    const group = this._syncGroups.get(appId)
    if (!group) throw new Error('Sync group not found: ' + appId)

    // Validate limit
    let limit = opts.limit || 100
    if (typeof limit !== 'number' || limit < 1) limit = 100
    if (limit > 1000) limit = 1000 // Max 1000 items

    // Validate prefix
    if (prefix && typeof prefix !== 'string') {
      throw new Error('Invalid prefix')
    }
    if (prefix && prefix.length > 1024) {
      throw new Error('Prefix too long')
    }

    await group.base.view.update()

    const results = []
    const rangeOpts = {}
    if (prefix) {
      rangeOpts.gte = prefix
      rangeOpts.lt = prefix + '\xff'
    }

    for await (const node of group.base.view.createReadStream(rangeOpts)) {
      if (results.length >= limit) break
      try {
        results.push({
          key: node.key,
          value: JSON.parse(node.value.toString())
        })
      } catch (err) {
        console.error('Failed to parse node value:', err.message)
      }
    }

    return results
  }

  /**
   * Range query with explicit bounds (gte/lte/lt/gt), optional reverse.
   *
   * Tickets-style apps need this for sorted pagination (e.g. attendees list
   * scrolled by registration time). More flexible than list(prefix, limit).
   *
   * opts: { gte?, lte?, gt?, lt?, limit?, reverse? }
   */
  async range (appId, opts = {}) {
    this._validateAppId(appId)
    const group = this._syncGroups.get(appId)
    if (!group) throw new Error('Sync group not found: ' + appId)

    // Validate + clamp limit
    let limit = opts.limit || 100
    if (typeof limit !== 'number' || limit < 1) limit = 100
    if (limit > 1000) limit = 1000

    // Validate bound strings
    const bound = (k) => {
      const v = opts[k]
      if (v === undefined || v === null) return undefined
      if (typeof v !== 'string' || v.length > 1024) throw new Error(`Invalid ${k} bound`)
      return v
    }

    const rangeOpts = {
      gte: bound('gte'),
      gt: bound('gt'),
      lte: bound('lte'),
      lt: bound('lt'),
      reverse: !!opts.reverse,
    }
    // Strip undefined so Hyperbee defaults apply cleanly
    Object.keys(rangeOpts).forEach((k) => rangeOpts[k] === undefined && delete rangeOpts[k])

    await group.base.view.update()
    const results = []
    for await (const node of group.base.view.createReadStream(rangeOpts)) {
      if (results.length >= limit) break
      try {
        results.push({ key: node.key, value: JSON.parse(node.value.toString()) })
      } catch (err) {
        console.error('range: failed to parse node:', err.message)
      }
    }
    return results
  }

  /**
   * Count entries under a prefix. O(n) scan but capped — useful for
   * dashboard tiles ("12 tickets sold") without fetching all the values.
   */
  async count (appId, prefix) {
    this._validateAppId(appId)
    const group = this._syncGroups.get(appId)
    if (!group) throw new Error('Sync group not found: ' + appId)

    if (prefix !== undefined && (typeof prefix !== 'string' || prefix.length > 1024)) {
      throw new Error('Invalid prefix')
    }

    await group.base.view.update()
    const rangeOpts = {}
    if (prefix) {
      rangeOpts.gte = prefix
      rangeOpts.lt = prefix + '\xff'
    }
    let n = 0
    const MAX = 100_000 // sanity cap
    for await (const _ of group.base.view.createReadStream(rangeOpts)) {
      if (++n >= MAX) break
    }
    return n
  }

  /**
   * Get sync group status
   */
  getSyncStatus (appId) {
    // Validate appId
    this._validateAppId(appId)
    const group = this._syncGroups.get(appId)
    if (!group) return null
    return {
      appId,
      inviteKey: group.inviteKey,
      writerCount: group.base.inputs?.length || 1,
      viewLength: group.base.view?.version || 0
    }
  }

  /**
   * POS-compatible apply function — matches sync-apply.js from Pear POS.
   * Handles product:create, product:update, product:delete, transaction:create,
   * config:set, stock:adjust, and more.
   */
  async _defaultApply (batch, view) {
    const b = view.batch({ update: false })

    for (const node of batch) {
      if (!node.value) continue
      let op
      try { op = JSON.parse(node.value.toString()) } catch (err) {
        console.error('Failed to parse operation:', err.message)
        continue
      }
      // Validate operation structure
      if (!op || typeof op !== 'object' || !op.type) {
        console.error('Invalid operation structure')
        continue
      }

      try {
        const k = (...parts) => parts.join('!')
        const enc = (obj) => Buffer.from(JSON.stringify(obj))
        const dec = (n) => n && n.value ? JSON.parse(n.value.toString()) : null

        switch (op.type) {
          case 'product:create': {
            const p = op.data
            await b.put(k('products', p.id), enc(p))
            if (p.barcode) await b.put(k('products-by-barcode', p.barcode), enc(p.id))
            if (p.sku) await b.put(k('products-by-sku', p.sku), enc(p.id))
            if (p.category) await b.put(k('products-by-category', p.category.toLowerCase(), p.id), enc(p.id))
            if (p.name) await b.put(k('products-by-name', p.name.toLowerCase(), p.id), enc(p.id))
            break
          }
          case 'product:update': {
            const { id, updates } = op.data
            const existing = dec(await b.get(k('products', id)))
            if (!existing) break
            const updated = { ...existing, ...updates, id, updated_at: op.timestamp }
            await b.put(k('products', id), enc(updated))
            if (updated.barcode) await b.put(k('products-by-barcode', updated.barcode), enc(id))
            if (updated.sku) await b.put(k('products-by-sku', updated.sku), enc(id))
            break
          }
          case 'product:delete': {
            const { id } = op.data
            const existing = dec(await b.get(k('products', id)))
            if (existing) await b.put(k('products', id), enc({ ...existing, active: false, updated_at: op.timestamp }))
            break
          }
          case 'stock:adjust': {
            const { product_id, delta } = op.data
            const existing = dec(await b.get(k('products', product_id)))
            if (existing) {
              existing.stock = (existing.stock || 0) + delta
              existing.updated_at = op.timestamp
              await b.put(k('products', product_id), enc(existing))
            }
            break
          }
          case 'transaction:create': {
            const tx = op.data
            const ts = tx.created_at || op.timestamp
            await b.put(k('transactions', ts, tx.id), enc(tx))
            await b.put(k('transactions-by-id', tx.id), enc(tx))
            break
          }
          case 'config:set': case 'merchant:register': {
            const existing = dec(await b.get('config!merchant'))
            await b.put('config!merchant', enc({ ...(existing || {}), ...op.data, updated_at: op.timestamp }))
            break
          }

          // ---- Tickets ops (13 handlers) ------------------------------------
          // Generic — used by any appId. Scoping lives in the caller's appId
          // so two different events go into two different sync groups.
          case 'event:create': {
            const e = op.data
            if (!e || !e.id) break
            await b.put(k('events', e.id), enc({ ...e, status: e.status || 'draft', created_at: op.timestamp }))
            if (e.name) await b.put(k('events-by-name', e.name.toLowerCase(), e.id), enc(e.id))
            break
          }
          case 'event:update': {
            const { id, updates } = op.data
            const existing = dec(await b.get(k('events', id)))
            if (!existing) break
            const next = { ...existing, ...updates, id, updated_at: op.timestamp }
            await b.put(k('events', id), enc(next))
            break
          }
          case 'event:publish': {
            const { id } = op.data
            const existing = dec(await b.get(k('events', id)))
            if (!existing) break
            await b.put(k('events', id), enc({ ...existing, status: 'live', published_at: op.timestamp }))
            break
          }
          case 'event:cancel': {
            const { id, reason } = op.data
            const existing = dec(await b.get(k('events', id)))
            if (!existing) break
            await b.put(k('events', id), enc({ ...existing, status: 'cancelled', cancel_reason: reason, updated_at: op.timestamp }))
            break
          }
          case 'ticket-type:create': {
            const t = op.data
            if (!t || !t.id || !t.event_id) break
            await b.put(k('ticket-types', t.event_id, t.id), enc({ ...t, created_at: op.timestamp }))
            break
          }
          case 'ticket-type:update': {
            const { event_id, id, updates } = op.data
            const existing = dec(await b.get(k('ticket-types', event_id, id)))
            if (!existing) break
            await b.put(k('ticket-types', event_id, id), enc({ ...existing, ...updates, updated_at: op.timestamp }))
            break
          }
          case 'ticket:mint': {
            const t = op.data
            if (!t || !t.id || !t.event_id) break
            const full = { ...t, status: t.status || 'issued', minted_at: op.timestamp }
            await b.put(k('tickets', t.event_id, t.id), enc(full))
            if (t.holder_pubkey) await b.put(k('tickets-by-holder', t.holder_pubkey, t.id), enc(t.id))
            break
          }
          case 'ticket:transfer': {
            const { event_id, id, new_holder_pubkey } = op.data
            const existing = dec(await b.get(k('tickets', event_id, id)))
            if (!existing) break
            if (existing.holder_pubkey) {
              await b.del(k('tickets-by-holder', existing.holder_pubkey, id))
            }
            const next = { ...existing, holder_pubkey: new_holder_pubkey, updated_at: op.timestamp }
            await b.put(k('tickets', event_id, id), enc(next))
            await b.put(k('tickets-by-holder', new_holder_pubkey, id), enc(id))
            break
          }
          case 'ticket:redeem': {
            const { event_id, id, redeemed_by } = op.data
            const existing = dec(await b.get(k('tickets', event_id, id)))
            if (!existing) break
            if (existing.status === 'redeemed') break // idempotent — first redeem wins
            const next = { ...existing, status: 'redeemed', redeemed_at: op.timestamp, redeemed_by }
            await b.put(k('tickets', event_id, id), enc(next))
            break
          }
          case 'ticket:refund': {
            const { event_id, id, reason } = op.data
            const existing = dec(await b.get(k('tickets', event_id, id)))
            if (!existing) break
            await b.put(k('tickets', event_id, id), enc({ ...existing, status: 'refunded', refund_reason: reason, updated_at: op.timestamp }))
            break
          }
          case 'ticket:void': {
            const { event_id, id, reason } = op.data
            const existing = dec(await b.get(k('tickets', event_id, id)))
            if (!existing) break
            await b.put(k('tickets', event_id, id), enc({ ...existing, status: 'void', void_reason: reason, updated_at: op.timestamp }))
            break
          }
          case 'attendee:register': {
            const a = op.data
            if (!a || !a.pubkey) break
            await b.put(k('attendees', a.pubkey), enc({ ...a, registered_at: op.timestamp }))
            if (a.email) await b.put(k('attendees-by-email', a.email.toLowerCase()), enc(a.pubkey))
            break
          }
          case 'venue:set': {
            const v = op.data
            if (!v || !v.event_id) break
            await b.put(k('venues', v.event_id), enc({ ...v, updated_at: op.timestamp }))
            break
          }
          // -------------------------------------------------------------------

          default: {
            // Generic fallback for unknown op types
            if (op.data && op.data.id) {
              await b.put(k(op.type.replace(':', '!'), op.data.id), enc(op.data))
            }
            break
          }
        }
      } catch (err) {
        console.error('Apply operation failed:', err.message)
      }
    }
    await b.flush()
  }

  async close () {
    for (const [, group] of this._syncGroups) {
      try { await this.swarm.leave(group.topic) } catch (err) {
        console.error('Failed to leave topic:', err.message)
      }
      // Close each group's Autobase to free its cores. Safe now that every base
      // owns a namespaced substore: base.close() closes only that session, never
      // the shared root Corestore (Hyperdrive/UserData/Names/replication).
      try { if (group.base) await group.base.close() } catch (err) {
        console.error('Failed to close sync group base:', err.message)
      }
    }
    this._syncGroups.clear()
  }
}

/**
 * Page-side shim injected into every `text/html` response served by the
 * HyperProxy. Adds `window.pear.swarm.v1` (and reserves `window.pear`
 * for future companion APIs).
 *
 * Talks to `/api/swarm/{join,send,leave,ticket,events}` over same-origin
 * fetch + EventSource. Bearer auth stays in `X-Pear-Token`; the EventSource
 * stream uses a one-time ticket minted via authenticated fetch.
 *
 * Page authors should always feature-detect:
 *
 *   if (window.pear?.swarm?.v1) {
 *     const ch = await window.pear.swarm.v1.join(null, { subtopic: 'rooms/lobby' })
 *     ch.on('peer', (p) => p.send(new TextEncoder().encode('hi')))
 *     ch.on('message', (peer, data) => { ... })
 *   }
 *
 * On the wire `data` is base64. The shim decodes to Uint8Array on the
 * way in and base64-encodes Uint8Array on the way out.
 */
const PEAR_SWARM_V1_SHIM = `<script>(function () {
  if (window.pear && window.pear.swarm && window.pear.swarm.v1) return
  function readToken () {
    var m = document.querySelector('meta[name="pear-api-token"]')
    return m ? m.content : ''
  }
  function b64encode (u8) {
    var s = ''
    for (var i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i])
    return btoa(s)
  }
  function b64decode (s) {
    var bin = atob(s)
    var u8 = new Uint8Array(bin.length)
    for (var i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i)
    return u8
  }
  async function rpc (method, path, body) {
    var headers = { 'X-Pear-Token': readToken() }
    if (body !== undefined) headers['Content-Type'] = 'application/json'
    var res = await fetch(path, {
      method: method,
      headers: headers,
      body: body === undefined ? undefined : JSON.stringify(body)
    })
    if (!res.ok) {
      var err = ''
      try { var j = await res.json(); err = (j && j.error) || res.statusText } catch (_) { err = res.statusText }
      throw new Error('pear.swarm: ' + err)
    }
    return res.json()
  }
  function makeChannel (info) {
    var listeners = { peer: [], message: [], 'peer-leave': [], error: [], closed: [] }
    var peers = new Map()
    var destroyed = false
    var es = null
    function emit (event) {
      var args = Array.prototype.slice.call(arguments, 1)
      var fns = listeners[event] || []
      for (var i = 0; i < fns.length; i++) {
        try { fns[i].apply(null, args) } catch (e) { console.error('[pear.swarm] listener threw:', e) }
      }
    }
    function makePeer (peerId, pubkey) {
      return {
        id: peerId,
        pubkey: pubkey || null,
        send: function (data) {
          if (destroyed) throw new Error('channel destroyed')
          var u8 = data instanceof Uint8Array ? data : new Uint8Array(data)
          rpc('POST', '/api/swarm/send', {
            channelId: info.channelId,
            peerId: peerId,
            data: b64encode(u8)
          }).catch(function (err) { emit('error', err) })
        }
      }
    }
    function openEventSource (url) {
      es = new EventSource(url)
      es.onmessage = function (ev) {
        var msg
        try { msg = JSON.parse(ev.data) } catch (_) { return }
        switch (msg.type) {
          case 'peer': {
            var peer = makePeer(msg.peerId, msg.pubkey)
            peers.set(msg.peerId, peer)
            emit('peer', peer); break
          }
          case 'peer-leave': {
            var p = peers.get(msg.peerId); peers.delete(msg.peerId)
            if (p) emit('peer-leave', p); break
          }
          case 'message': {
            var peerObj = peers.get(msg.peerId)
            if (peerObj) emit('message', peerObj, b64decode(msg.data)); break
          }
          case 'error': emit('error', new Error(msg.message || 'swarm error')); break
          case 'closed': channel.destroy(); break
        }
      }
      es.onerror = function () {
        if (!destroyed) { try { es.close() } catch (_) {} channel.destroy() }
      }
    }
    function attachStream () {
      rpc('POST', '/api/swarm/ticket', { channelId: info.channelId }).then(function (result) {
        if (destroyed) return
        if (!result || !result.ticket) throw new Error('missing swarm stream ticket')
        var url = '/api/swarm/events?channelId=' + encodeURIComponent(info.channelId)
          + '&ticket=' + encodeURIComponent(result.ticket)
        openEventSource(url)
      }).catch(function (err) {
        if (destroyed) return
        emit('error', err)
        channel.destroy()
      })
    }
    var channel = {
      channelId: info.channelId,
      topic: info.topicHex,
      topicHex: info.topicHex,
      protocol: info.protocol,
      version: info.version,
      tier: info.tier,
      get peers () { return Array.from(peers.values()) },
      on: function (event, fn) {
        if (!listeners[event]) listeners[event] = []
        listeners[event].push(fn)
      },
      off: function (event, fn) {
        var arr = listeners[event] || []
        var i = arr.indexOf(fn); if (i >= 0) arr.splice(i, 1)
      },
      destroy: function () {
        if (destroyed) return
        destroyed = true
        try { if (es) es.close() } catch (_) {}
        rpc('POST', '/api/swarm/leave', { channelId: info.channelId }).catch(function () {})
        emit('closed')
      }
    }
    attachStream()
    return channel
  }
  var swarmV1 = {
    join: async function (topicHex, opts) {
      opts = opts || {}
      var info = await rpc('POST', '/api/swarm/join', {
        topicHex: topicHex || null,
        subtopic: opts.subtopic === undefined ? null : opts.subtopic,
        protocol: opts.protocol || 'pear.swarm.v1',
        version: opts.version === undefined ? 1 : opts.version,
        server: !!opts.server,
        client: opts.client !== false,
        appName: opts.appName || (document.title || null),
        reason: opts.reason || null
      })
      return makeChannel(info)
    }
  }
  if (!window.pear) window.pear = {}
  if (!window.pear.swarm) window.pear.swarm = {}
  window.pear.swarm.v1 = swarmV1
})();</script>`

/**
 * Page-side window.pear.sync + window.pear.identity shim. Thin, token-
 * authenticated fetch wrappers over the /api/sync/* and /api/identity/*
 * endpoints the HttpBridge already serves on the same loopback port.
 *
 * Without this, any page that builds on window.pear.sync (peerit,
 * p2pbuilders, pear-exchange, …) never detects a data bridge and silently
 * falls back to its localStorage "dev" mode — single-user, not actually
 * peer-to-peer. We inject only swarm.v1 before this; this completes the
 * surface so multi-writer P2P apps work in the browser.
 *
 * Security: the HttpBridge scopes EVERY appId by the page's own drive key
 * (derived from the per-page api-token via _scopeAppId), so a page can only
 * read/write its own app's data — it cannot reach another app's records even
 * though the endpoint paths are generic. The shim therefore only needs to
 * forward the token (X-Pear-Token), exactly like the swarm shim.
 */
const PEAR_SYNC_SHIM = `<script>(function () {
  if (window.pear && window.pear.sync && window.pear.identity && window.pear.ai) return
  function readToken () {
    var m = document.querySelector('meta[name="pear-api-token"]')
    return m ? m.content : ''
  }
  async function apiGet (path) {
    var res = await fetch(path, { headers: { 'X-Pear-Token': readToken() } })
    if (!res.ok) {
      var err = ''
      try { var j = await res.json(); err = (j && j.error) || res.statusText } catch (_) { err = res.statusText }
      throw new Error('pear.sync: ' + err)
    }
    return res.json()
  }
  async function apiPost (path, body) {
    var res = await fetch(path, {
      method: 'POST',
      headers: { 'X-Pear-Token': readToken(), 'Content-Type': 'application/json' },
      body: JSON.stringify(body || {})
    })
    if (!res.ok) {
      var err = ''
      try { var j = await res.json(); err = (j && j.error) || res.statusText } catch (_) { err = res.statusText }
      throw new Error('pear.sync: ' + err)
    }
    return res.json()
  }
  var sync = {
    create: function (appId) { return apiPost('/api/sync/create', { appId: appId }) },
    join: function (appId, inviteKey) { return apiPost('/api/sync/join', { appId: appId, inviteKey: inviteKey }) },
    append: function (appId, op) { return apiPost('/api/sync/append', { appId: appId, op: op }) },
    get: function (appId, key) { return apiGet('/api/sync/get?appId=' + encodeURIComponent(appId) + '&key=' + encodeURIComponent(key)) },
    list: function (appId, prefix, opts) {
      var url = '/api/sync/list?appId=' + encodeURIComponent(appId)
      if (prefix) url += '&prefix=' + encodeURIComponent(prefix)
      if (opts && opts.limit) url += '&limit=' + opts.limit
      return apiGet(url)
    },
    range: function (appId, opts) {
      var url = '/api/sync/range?appId=' + encodeURIComponent(appId)
      if (opts && opts.gte) url += '&gte=' + encodeURIComponent(opts.gte)
      if (opts && opts.gt) url += '&gt=' + encodeURIComponent(opts.gt)
      if (opts && opts.lte) url += '&lte=' + encodeURIComponent(opts.lte)
      if (opts && opts.lt) url += '&lt=' + encodeURIComponent(opts.lt)
      if (opts && opts.reverse) url += '&reverse=1'
      if (opts && opts.limit) url += '&limit=' + opts.limit
      return apiGet(url)
    },
    count: function (appId, prefix) {
      var url = '/api/sync/count?appId=' + encodeURIComponent(appId)
      if (prefix) url += '&prefix=' + encodeURIComponent(prefix)
      return apiGet(url)
    },
    status: function (appId) { return apiGet('/api/sync/status?appId=' + encodeURIComponent(appId)) }
  }
  var identity = {
    getPublicKey: function () { return apiGet('/api/identity') },
    sign: function (payload, namespace) { return apiPost('/api/identity/sign', { payload: String(payload), namespace: namespace || '' }) }
  }
  var ai = {
    capabilities: function () { return apiGet('/api/ai/capabilities') },
    cancel: function (requestId) { return apiPost('/api/ai/cancel', { requestId: String(requestId || '') }) },
    complete: async function (input) {
      var res = await fetch('/api/ai/completions', {
        method: 'POST',
        headers: { 'X-Pear-Token': readToken(), 'Content-Type': 'application/json' },
        body: JSON.stringify(input || {})
      })
      if (!res.ok) {
        var message = res.statusText
        try { var json = await res.json(); message = (json && json.error) || message } catch (_) {}
        throw new Error('pear.ai: ' + message)
      }
      var requestId = res.headers.get('X-Pear-AI-Request-Id') || ''
      var reader = res.body && res.body.getReader ? res.body.getReader() : null
      if (!reader) throw new Error('pear.ai: streaming response body unavailable')
      var decoder = new TextDecoder()
      var buffered = ''
      async function * events () {
        while (true) {
          var next = await reader.read()
          if (next.done) break
          buffered += decoder.decode(next.value, { stream: true })
          var newline
          while ((newline = buffered.indexOf('\\n')) !== -1) {
            var line = buffered.slice(0, newline)
            buffered = buffered.slice(newline + 1)
            if (line) yield JSON.parse(line)
          }
        }
        buffered += decoder.decode()
        if (buffered.trim()) yield JSON.parse(buffered)
      }
      return {
        requestId: requestId,
        events: events(),
        cancel: function () { return ai.cancel(requestId) }
      }
    }
  }
  if (!window.pear) window.pear = {}
  if (!window.pear.sync) window.pear.sync = sync
  if (!window.pear.identity) window.pear.identity = identity
  if (!window.pear.ai) window.pear.ai = ai
})();</script>`

/**
 * Page-side shim injected into HTML responses ONLY when the loaded
 * Hyperdrive is the anonGPT drive AND its manifest.json declares the
 * required privacy claims. See:
 *   - backend/constants.js ANONGPT_DRIVE_KEY
 *   - backend/hyper-proxy.js _shouldInjectAnongptShim()
 *   - backend/anongpt-buyer.js
 *   - anongpt/docs/spec/02-pearbrowser-dev-bridge.md
 *
 * The page-side surface is exactly one method — `infer({...})` — that
 * fetches /api/anongpt/infer with the X-Pear-Token meta the proxy
 * injects alongside this script. No generic DHT, no fs, no shell, no
 * direct access to the seller transport. The privacy contract demands
 * the page never receives buyer/seller private keys, so the entire
 * crypto path lives on the worklet side.
 *
 * Page authors should always feature-detect:
 *
 *   if (window.pear?.anongpt?.infer) {
 *     const r = await window.pear.anongpt.infer({
 *       input, sellerPubkey, options: { maxTokens: 160 }, rateCard
 *     })
 *     if (!r.ok) { ... handle fail-closed ... }
 *   }
 */
const PEAR_ANONGPT_SHIM = `<script>(function () {
  if (window.pear && window.pear.anongpt && window.pear.anongpt.infer) return
  function readToken () {
    var m = document.querySelector('meta[name="pear-api-token"]')
    return m ? m.content : ''
  }
  async function infer (req) {
    var headers = {
      'Content-Type': 'application/json',
      'X-Pear-Token': readToken()
    }
    var res = await fetch('/api/anongpt/infer', {
      method: 'POST',
      headers: headers,
      body: JSON.stringify(req || {})
    })
    var json = await res.json().catch(function () { return null })
    if (!res.ok) {
      // Surface the structured error to the page exactly as the
      // worklet sent it. Do NOT throw — pages must distinguish
      // "buyer not implemented" from "network error" without a
      // try/catch around every call.
      if (json && typeof json === 'object') return json
      return {
        ok: false,
        code: 'http-error',
        message: 'anongpt.infer: HTTP ' + res.status + ' ' + res.statusText
      }
    }
    if (!json || typeof json !== 'object') {
      return {
        ok: false,
        code: 'bad-json',
        message: 'anongpt.infer: worklet returned a non-JSON response',
        verify: { ok: false, reason: 'bad-json' }
      }
    }
    if (json.ok === true) {
      // Trust must come from the worklet's local verifier. If that field is
      // missing or malformed, coerce to unverified rather than allowing a
      // successful response to become implicitly trusted.
      if (!json.verify || typeof json.verify !== 'object') {
        json.verify = { ok: false, reason: 'missing-local-verify' }
      } else if (json.verify.ok !== true) {
        json.verify.ok = false
      }
    }
    return json
  }
  if (!window.pear) window.pear = {}
  if (!window.pear.anongpt) window.pear.anongpt = {}
  window.pear.anongpt.infer = infer
})();</script>`

module.exports = { PearBridge, PEAR_SWARM_V1_SHIM, PEAR_SYNC_SHIM, PEAR_ANONGPT_SHIM }