OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / backend / search-frontier.cjs

110 lines · javascript ref:
// Lighthouse Phase 3 — multi-hop frontier fan-out. PURE, CommonJS. Two pieces:
//  1. IndexPointer — a tiny, root-signed DHT record mapping a contact's root
//     pubkey → their current search-index core key (resolved at app start;
//     verified against the Contacts-held root, like the IdentityBinding).
//  2. planFanout — the connection-budget algorithm that keeps networked fan-out
//     inside the SwarmBridge caps (8 channels / 10 joins-per-min are a
//     per-page-webview throttle; first-party search joins the shared swarm
//     directly under its OWN budget). Digest-FIRST: open a full-shard
//     replication only to peers whose cheap digest says they might match, warm
//     sessions are reused, and NEW cold connects are hard-capped per query so a
//     large frontier can't blow the budget. See docs/P2P-SEARCH-RESEARCH.md.

const crypto = require('hypercore-crypto')
const b4a = require('b4a')
const dg = require('./search-digest.cjs')

// --- IndexPointer (DHT) ------------------------------------------------------

const POINTER_TAG = 'pear.lighthouse.indexptr.v1:'
function canonPointer (rootPubkey, indexKey, version) {
  return POINTER_TAG + JSON.stringify({ r: rootPubkey, i: indexKey, v: version }, ['i', 'r', 'v'])
}

// `rootSign(msgString) -> sigHex` (identity.sign in the app).
function makeIndexPointer ({ rootPubkey, indexKey, version }, rootSign) {
  if (!Number.isInteger(version) || version < 1) throw new Error('version must be a positive integer')
  if (typeof rootPubkey !== 'string' || !rootPubkey) throw new Error('rootPubkey required')
  if (typeof indexKey !== 'string' || !indexKey) throw new Error('indexKey required')
  return { kind: 'indexptr', rootPubkey, indexKey, version, sig: rootSign(canonPointer(rootPubkey, indexKey, version)) }
}

function verifyIndexPointer (ptr, expectedRootPubkey) {
  if (!ptr || ptr.kind !== 'indexptr' || ptr.rootPubkey !== expectedRootPubkey) return false
  // canonical integer version (mirror verifyBinding/verifyAnchor) so a string
  // version can't beat legit ones / make resolveIndexKey order-dependent.
  if (!Number.isInteger(ptr.version) || ptr.version < 1) return false
  try {
    return crypto.verify(b4a.from(canonPointer(ptr.rootPubkey, ptr.indexKey, ptr.version), 'utf-8'),
      b4a.from(ptr.sig, 'hex'), b4a.from(expectedRootPubkey, 'hex'))
  } catch { return false }
}

// Pick the highest-version verified pointer for a root → its index key, or null.
function resolveIndexKey (expectedRootPubkey, pointers) {
  let best = null
  for (const p of pointers || []) {
    if (!p || typeof p.indexKey !== 'string' || !p.indexKey) continue
    if (!verifyIndexPointer(p, expectedRootPubkey)) continue
    // highest version wins; equal-version ties broken deterministically by
    // indexKey so resolution is independent of pointer array order.
    if (!best || p.version > best.version ||
      (p.version === best.version && p.indexKey < best.indexKey)) best = p
  }
  return best ? best.indexKey : null
}

// --- Fan-out budget planner --------------------------------------------------

const DEFAULT_BUDGET = {
  maxFrontier: 64,         // most trust-ranked peers considered per query
  maxConnectsPerQuery: 4,  // NEW cold swarm connections opened per query
  maxLiveSessions: 24,     // ceiling on concurrently-held replication sessions
}

// Decide which frontier peers to pull a full shard from this query.
// frontier: [{ rootPubkey, trustHop, digest, warm }]. `warm` = an open/cached
// replication session (≈ free). Returns { pull, deferred, skipped, connects }.
function planFanout (frontier, queryTerms, budget = {}) {
  const b = { ...DEFAULT_BUDGET, ...budget }
  // consider the closest (lowest-hop) peers first; ties stable by rootPubkey
  const ranked = [...(frontier || [])]
    .sort((a, c) => (a.trustHop - c.trustHop) || (String(a.rootPubkey) < String(c.rootPubkey) ? -1 : 1))
    .slice(0, b.maxFrontier)

  // digest-first: only peers whose digest advertises a query term are worth pulling
  const hits = ranked.filter((p) => dg.digestWorthPulling(p.digest, queryTerms))
  const skipped = ranked.filter((p) => !dg.digestWorthPulling(p.digest, queryTerms))

  const warm = hits.filter((p) => p.warm)            // reuse existing sessions — no new connect
  const cold = hits.filter((p) => !p.warm)

  // Sessions held against the ceiling = ALL warm peers we currently hold open,
  // not just those matching THIS query (a replication session is held
  // regardless of the current term). Counting only query-matching warm peers
  // would overshoot maxLiveSessions.
  const liveHeld = (frontier || []).filter((p) => p && p.warm).length
  const connectSlots = Math.max(0, Math.min(b.maxConnectsPerQuery, b.maxLiveSessions - liveHeld))
  const coldPull = cold.slice(0, connectSlots)
  const deferred = cold.slice(connectSlots)          // background / next round

  return { pull: [...warm, ...coldPull], deferred, skipped, connects: coldPull.length }
}

// Build frontier entries from contacts + a trust graph + known digests/warm set.
function buildFrontier (contacts, graph, { digests = new Map(), warm = new Set() } = {}) {
  const out = []
  for (const root of contacts || []) {
    const hop = graph.hopOf(root)
    if (!Number.isFinite(hop) || hop === 0) continue // skip self + unreachable
    out.push({ rootPubkey: root, trustHop: hop, digest: digests.get(root) || null, warm: warm.has(root) })
  }
  return out
}

module.exports = {
  DEFAULT_BUDGET,
  canonPointer, makeIndexPointer, verifyIndexPointer, resolveIndexKey,
  planFanout, buildFrontier,
}