OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / backend / search-federation.cjs

177 lines · javascript ref:
// Lighthouse Phase 1 — signed-descriptor federation (discovery: intent→key).
// PURE + framework-free, CommonJS. The discovery half of search: resolve a
// query against many small SIGNED descriptor rooms (schema-sheets) plus your
// personal index, ranked client-side over your trust graph (followed curators
// > default set). See docs/P2P-SEARCH-RESEARCH.md.
//
// This module owns the trust graph (hop distance → tier), the dedup-and-rank
// MERGE across sources, the descriptor-room schemas, and the row→candidate
// mapper. The actual room I/O (loading/querying a sheets room) integrates with
// sheets-catalog.js on the live-wiring pass; the ranking + trust logic here is
// the testable engine.

const sc = require('./search-core.cjs')
const b4a = require('b4a')

// schema-sheets per-row verified author provenance is `memberkey` (a Buffer);
// normalize to hex. Matches resourceRowToCandidate + sheets-catalog rowToApp.
function memberRootHex (v) {
  if (v == null) return ''
  if (typeof v === 'string') return v
  try { return b4a.toString(v, 'hex') } catch { return '' }
}

// schema-sheets schema for a RESOURCE descriptor room: a signed pointer that
// supplies the human-meaningful corner of Zooko's triangle for a hyper:// key.
const RESOURCE_SCHEMA = {
  type: 'object',
  required: ['name'],
  additionalProperties: true,
  properties: {
    name: { type: 'string', maxLength: 200 },
    description: { type: 'string', maxLength: 2000 },
    driveKey: { type: 'string', maxLength: 128 },
    link: { type: 'string', maxLength: 256 },          // pear:// or hyper://
    path: { type: 'string', maxLength: 512 },
    kind: { type: 'string', maxLength: 40 },           // app | site | dataset | feed | doc
    keywords: { type: 'array', items: { type: 'string', maxLength: 64 }, maxItems: 64 },
    publishedAt: { type: 'number' },
  },
}

// schema-sheets schema for a TRUST room: a signed "I follow this curator" edge.
// The room is keyed/authored by your root identity (signed provenance); each
// row vouches for another root pubkey as a curator you trust.
const TRUST_SCHEMA = {
  type: 'object',
  required: ['curatorRoot'],
  additionalProperties: true,
  properties: {
    curatorRoot: { type: 'string', maxLength: 128 },   // hex root pubkey you follow
    label: { type: 'string', maxLength: 120 },
    weight: { type: 'number' },                        // optional 0..1 emphasis
    addedAt: { type: 'number' },
  },
}

// Build a trust graph from follow edges and resolve hop distance / tier from
// YOUR root. edges: [{ from: rootHex, to: rootHex }] (from follows to). hop 0 =
// self; 1..maxFollowHops = followed (directly or transitively); beyond = default.
function buildTrustGraph (selfRoot, edges, { maxFollowHops = 2 } = {}) {
  const adj = new Map()
  for (const e of edges || []) {
    if (!e || !e.from || !e.to) continue
    if (!adj.has(e.from)) adj.set(e.from, new Set())
    adj.get(e.from).add(e.to)
  }
  const hop = new Map([[selfRoot, 0]])
  let frontier = [selfRoot]
  for (let h = 1; h <= maxFollowHops && frontier.length; h++) {
    const next = []
    for (const u of frontier) {
      for (const v of (adj.get(u) || [])) {
        if (!hop.has(v)) { hop.set(v, h); next.push(v) }
      }
    }
    frontier = next
  }
  return {
    maxFollowHops,
    hopOf (root) { return hop.has(root) ? hop.get(root) : Infinity },
    tierOf (root) {
      const h = hop.has(root) ? hop.get(root) : Infinity
      if (h === 0) return 'self'
      return h <= maxFollowHops ? 'followed' : 'default'
    },
  }
}

// Convert a TRUST room's rows into follow edges rooted at you. A row authored
// by `selfRoot` that names `curatorRoot` is an edge self→curator; transitive
// edges come from replicating a followed curator's own trust room.
function trustRowsToEdges (rows) {
  const edges = []
  for (const r of rows || []) {
    if (!r) continue
    // Provenance must come from VERIFIED fields ONLY: the row's `memberkey`
    // (the sheets layer's per-row signed author) and the signed body's
    // `json.curatorRoot`. Unsigned top-level `.from`/`.to`/`.curatorRoot` are
    // attacker-controllable — accepting them would let anyone forge a follow
    // edge and promote a Sybil into the followed tier.
    const from = memberRootHex(r.memberkey)
    const to = r.json && r.json.curatorRoot
    // curatorRoot must be a hex STRING (TRUST_SCHEMA declares it so); a
    // Buffer/number would be mis-keyed and silently demoted to the default tier.
    if (from && typeof to === 'string' && to) edges.push({ from, to })
  }
  return edges
}

// Map a RESOURCE descriptor row → a ranking candidate. `tf` is the query-match
// strength the caller computed (e.g. count of query terms present in
// name+keywords); defaults to 1 for a plain listing.
function resourceRowToCandidate (row, tf = 1) {
  const j = (row && row.json) || row || {}
  const driveKey = j.driveKey || j.link || ''
  const path = j.path || '/'
  return {
    docId: sc.docIdFor(driveKey, path),
    driveKey,
    link: j.link || null,
    path,
    title: j.name || driveKey,
    tf,
    publishedAt: j.publishedAt || 0,
    contentHash: '',
    // hex-normalize the Buffer memberkey so the mergeFederated dedup tie-break
    // (lexicographic on signerPubkey) is deterministic across peers — a raw
    // Buffer compares via lossy utf-8 coercion.
    signerPubkey: memberRootHex(row && row.memberkey),
  }
}

// Tag a candidate with the trust tier/hop of the source root that vouched for it.
function tagCandidate (c, sourceRoot, graph) {
  const hop = graph.hopOf(sourceRoot)
  return { ...c, trustHop: Number.isFinite(hop) ? hop : 99, tier: graph.tierOf(sourceRoot) }
}

// Merge candidates from many sources, dedup by document (best trust wins, then
// best text match), and rank with the deterministic search-core ranker.
// sources: [{ rootPubkey, candidates: [candidate...] }]. `now0` threads into
// the ranker (recency) without any clock read in the fold.
function mergeFederated (sources, graph, { now0 = 0, limit = 50 } = {}) {
  const byDoc = new Map()
  for (const src of sources || []) {
    if (!src) continue
    const root = src.rootPubkey
    for (const c of (src.candidates || [])) {
      if (!c) continue
      const tagged = tagCandidate(c, root, graph)
      const key = c.docId || (c.driveKey + '|' + (c.path || '/'))
      const prev = byDoc.get(key)
      // best trust (lowest hop) wins, then best text match, then a deterministic
      // signerPubkey/contentHash tie-break so the retained copy is independent
      // of `sources` iteration order (cross-peer reproducibility).
      const sig = (x) => x.signerPubkey || ''
      const ch = (x) => x.contentHash || ''
      const better = !prev ||
        tagged.trustHop < prev.trustHop ||
        (tagged.trustHop === prev.trustHop && (tagged.tf || 0) > (prev.tf || 0)) ||
        (tagged.trustHop === prev.trustHop && (tagged.tf || 0) === (prev.tf || 0) &&
          (sig(tagged) < sig(prev) || (sig(tagged) === sig(prev) && ch(tagged) < ch(prev))))
      if (better) byDoc.set(key, tagged)
    }
  }
  // explicit null/NaN/Infinity limit coalesces back to the default rather than
  // 0 results (Number(null)===0 would otherwise slip through).
  const lim = limit == null ? 50 : Number(limit)
  const n = Number.isFinite(lim) && lim >= 0 ? Math.floor(lim) : 50
  return sc.rankCandidates([...byDoc.values()], { now0 }).slice(0, n)
}

module.exports = {
  RESOURCE_SCHEMA, TRUST_SCHEMA,
  buildTrustGraph, trustRowsToEdges, resourceRowToCandidate, tagCandidate, mergeFederated,
}