OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / backend / federated-nostr-feed.cjs

145 lines · javascript ref:
// NOSTR Phase 3 — the federated feed. Replicate each TRUSTED contact's event
// store and surface only events authored by THAT contact's ATTESTED nostr key
// (verified via their published nostr-bind against the Contacts-held root — the
// cross-curve MITM defense). Per-contact trust set, so contact A can't serve an
// event you'd trust as authored by someone else. Parallel + per-step timeout;
// future-dated events dropped (display anti-spam — a peer can't pin a note to the
// top of your feed; the consensus reducer stays clock-free). PURE w.r.t. injected
// deps (contacts, binding resolver, store opener, clock) → Node-testable.

const { buildNostrTrustSet, partitionByTrust } = require('./nostr-ingest.cjs')

const DEFAULT_MAX_CONTACTS = 64
const DEFAULT_STEP_TIMEOUT_MS = 3000
const FUTURE_SKEW_SEC = 900 // events dated > now + 15min are dropped from the feed

function withTimeout (p, ms) {
  let t = null
  return Promise.race([
    Promise.resolve(p).finally(() => { if (t) clearTimeout(t) }),
    new Promise((_, reject) => { t = setTimeout(() => reject(new Error('timeout')), ms) }),
  ])
}

function makeHiddenDiagnostics (contactsEligible = 0) {
  return {
    contactsEligible,
    contactsResolved: 0,
    bindingMissing: 0,
    bindingUntrusted: 0,
    quarantined: 0,
    dropped: 0,
    futureDated: 0,
    contactFailures: 0,
    byReason: {}
  }
}

function addHiddenReason (hidden, reason, count = 1) {
  const key = reason || 'unverified'
  hidden.byReason[key] = (hidden.byReason[key] || 0) + count
}

function mergeHiddenDiagnostics (into, from) {
  for (const key of ['contactsResolved', 'bindingMissing', 'bindingUntrusted', 'quarantined', 'dropped', 'futureDated', 'contactFailures']) {
    into[key] += from[key] || 0
  }
  for (const [reason, count] of Object.entries(from.byReason || {})) {
    addHiddenReason(into, reason, count)
  }
}

class FederatedNostrFeed {
  // deps:
  //   listContacts() -> [{ pubkey (root hex), displayName, verifiedAt, bindingKey }]
  //   resolveBinding({ contactPubkey, dhtPubkey }) -> { nostrEventKey, nostrBind, nostrRevocations } | null
  //   openEventStore(nostrEventKeyHex, contactPubkey) -> { listEvents() -> [event] } | null
  //   now() -> unix seconds (injectable for tests)
  constructor ({ listContacts, resolveBinding, openEventStore, now, maxContacts, stepTimeoutMs } = {}) {
    if (typeof listContacts !== 'function') throw new Error('FederatedNostrFeed requires listContacts')
    if (typeof resolveBinding !== 'function') throw new Error('FederatedNostrFeed requires resolveBinding')
    if (typeof openEventStore !== 'function') throw new Error('FederatedNostrFeed requires openEventStore')
    this.listContacts = listContacts
    this.resolveBinding = resolveBinding
    this.openEventStore = openEventStore
    this.now = typeof now === 'function' ? now : () => Math.floor(Date.now() / 1000)
    this.maxContacts = Number.isInteger(maxContacts) && maxContacts > 0 ? maxContacts : DEFAULT_MAX_CONTACTS
    this.stepTimeoutMs = Number.isInteger(stepTimeoutMs) && stepTimeoutMs > 0 ? stepTimeoutMs : DEFAULT_STEP_TIMEOUT_MS
  }

  async _collect () {
    const contacts = ((await this.listContacts()) || [])
      .filter((c) => c && c.verifiedAt && typeof c.pubkey === 'string' && c.bindingKey)
      .slice(0, this.maxContacts)
    const nowSec = this.now()
    const ceiling = nowSec + FUTURE_SKEW_SEC
    const hidden = makeHiddenDiagnostics(contacts.length)
    const settled = await Promise.allSettled(contacts.map(async (c) => {
      const contactHidden = makeHiddenDiagnostics()
      const binding = await withTimeout(this.resolveBinding({ contactPubkey: c.pubkey, dhtPubkey: c.bindingKey }), this.stepTimeoutMs)
      contactHidden.contactsResolved = 1
      if (!binding || !binding.nostrEventKey || !binding.nostrBind) {
        contactHidden.bindingMissing = 1
        addHiddenReason(contactHidden, 'missing-binding')
        return { events: [], hidden: contactHidden }
      }
      // PER-CONTACT trust set: only THIS contact's attested nostr key is trusted in
      // THEIR store. buildNostrTrustSet re-verifies the nostr-bind (dual-sig) against
      // the contact's root and applies remote revocations, so a forged, revoked,
      // or stale bind yields an empty/non-linked set.
      const trust = buildNostrTrustSet([c], () => ({
        binds: [binding.nostrBind],
        revocations: Array.isArray(binding.nostrRevocations) ? binding.nostrRevocations : [],
      }))
      if (trust.size === 0) {
        contactHidden.bindingUntrusted = 1
        const state = trust.rootStates instanceof Map ? trust.rootStates.get(c.pubkey.toLowerCase()) : null
        addHiddenReason(contactHidden, state?.status || 'untrusted-binding')
      }
      const store = await withTimeout(this.openEventStore(binding.nostrEventKey, c.pubkey), this.stepTimeoutMs)
      if (!store) return { events: [], hidden: contactHidden }
      const evs = await withTimeout(store.listEvents(), this.stepTimeoutMs)
      const { accepted, quarantined, dropped } = partitionByTrust(evs, trust)
      const via = c.displayName || c.pubkey.slice(0, 8)
      contactHidden.quarantined += quarantined.length
      for (const ev of quarantined) addHiddenReason(contactHidden, ev._trustState || 'unverified')
      contactHidden.dropped += dropped.length
      for (const d of dropped) addHiddenReason(contactHidden, d.reason ? `invalid:${d.reason}` : 'invalid-event')
      const visible = accepted.filter((ev) => ev.created_at <= ceiling)
      const futureDated = accepted.length - visible.length
      if (futureDated > 0) {
        contactHidden.futureDated += futureDated
        addHiddenReason(contactHidden, 'future-dated', futureDated)
      }
      return {
        events: visible.map((ev) => ({ ...ev, _via: via })),
        hidden: contactHidden
      }
    }))
    const byId = new Map()
    for (const r of settled) {
      if (r.status !== 'fulfilled' || !r.value) {
        hidden.contactFailures += 1
        addHiddenReason(hidden, 'contact-failed')
        continue
      }
      mergeHiddenDiagnostics(hidden, r.value.hidden || makeHiddenDiagnostics())
      for (const ev of r.value.events || []) if (!byId.has(ev.id)) byId.set(ev.id, ev)
    }
    return { events: [...byId.values()], hidden }
  }

  // Returns accepted contact events, each tagged with `_via` (the source contact's
  // display name) for attribution. Deduped by id; NOT sorted/capped (the caller
  // merges with the user's own events and applies the final NIP-01 query).
  async events () {
    return (await this._collect()).events
  }

  async eventsWithDiagnostics () {
    return this._collect()
  }
}

module.exports = { FederatedNostrFeed, FUTURE_SKEW_SEC }