OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / scripts / verify-pin.js

317 lines · javascript ref:
/**
 * Verify that a pinned drive is actually being served by the network.
 *
 * Different from check-relays.js (which only proves relays are
 * reachable) and pin-self-on-hiverelay.js (which only proves they
 * accept seed requests). This script does the real round-trip:
 *
 *   1. Boot a fresh Corestore + Hyperswarm with NO local cache
 *   2. Open an explicitly supplied Hyperdrive key
 *   3. Join the swarm and wait for any peer (relay or otherwise)
 *   4. Read the drive's current length and a sample blob
 *   5. Compare against an expected length, if provided
 *   6. Exit 0 on success, non-zero on failure
 *
 * If this exits 0, the drive is genuinely available from a third party. It is
 * content availability evidence only; it does not approve a native package or
 * execute a legacy Pear v2 project.
 *
 * Usage:
 *   # Verify an explicit content/evidence drive is reachable
 *   node scripts/verify-pin.js --key <64-hex>
 *
 *   # Verify a specific length is being served
 *   node scripts/verify-pin.js --key <64-hex> --expect 4914
 *
 *   # Also ask upgraded HiveRelay relays for signed proveSeeded evidence
 *   node scripts/verify-pin.js --key <64-hex> --expect 4914 --hiverelay
 *
 * Designed to be called from CI / release-prod.sh — fails loudly if
 * the release hasn't propagated to the network within the timeout.
 */

import Hyperswarm from 'hyperswarm'
import Corestore from 'corestore'
import Hyperdrive from 'hyperdrive'
import b4a from 'b4a'
import { HiveRelayClient } from 'p2p-hiverelay-client'
import { mkdtempSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'

const PEER_TIMEOUT_MS = 30_000
const LENGTH_TIMEOUT_MS = 20_000
const BLOB_SAMPLE_TIMEOUT_MS = 20_000
const BLOB_LIST_TIMEOUT_MS = 20_000
const HIVERELAY_TIMEOUT_MS = 45_000
const HIVERELAY_PROOF_TIMEOUT_MS = 30_000
const DEFAULT_SAMPLE_PATHS = ['/index.html', '/package.json', '/electron/main.cjs']

function parseArgs (argv) {
  const args = {
    key: null,
    expect: null,
    hiverelay: false,
    requireHiverelay: false,
    verifySeededFallback: false,
    samples: 3
  }
  for (let i = 0; i < argv.length; i++) {
    if (argv[i] === '--key') args.key = argv[++i]
    else if (argv[i] === '--expect') args.expect = parseInt(argv[++i], 10)
    else if (argv[i] === '--hiverelay') args.hiverelay = true
    else if (argv[i] === '--require-hiverelay') {
      args.hiverelay = true
      args.requireHiverelay = true
    } else if (argv[i] === '--verify-seeded-fallback') {
      args.verifySeededFallback = true
    } else if (argv[i] === '--samples') {
      args.samples = Math.max(1, Math.min(parseInt(argv[++i], 10) || 3, 16))
    }
  }
  return args
}

const args = parseArgs(process.argv.slice(2))
if (!/^[0-9a-f]{64}$/i.test(args.key)) {
  console.error('error: --key <64-char-hex-hyperdrive-key> is required')
  process.exit(2)
}

console.log('🍐 Verifying drive is reachable from the network')
console.log('   key:', args.key)
if (args.expect != null) console.log('   expect length ≥', args.expect)
console.log()

const storage = mkdtempSync(join(tmpdir(), 'verify-pin-'))
const store = new Corestore(storage)
const swarm = new Hyperswarm()
const drive = new Hyperdrive(store, b4a.from(args.key, 'hex'))

swarm.on('connection', (conn) => store.replicate(conn))

let peerCount = 0
swarm.on('connection', () => { peerCount += 1 })

const cleanup = async (code) => {
  try { await swarm.destroy() } catch {}
  try { await store.close() } catch {}
  try { rmSync(storage, { recursive: true, force: true }) } catch {}
  process.exit(code)
}

function waitForHiveRelay (client, timeoutMs) {
  if (client.relays && client.relays.size > 0) return Promise.resolve()
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      cleanupListeners()
      reject(new Error(`no HiveRelay relays discovered within ${timeoutMs / 1000}s`))
    }, timeoutMs)
    const onRelay = () => {
      cleanupListeners()
      resolve()
    }
    const cleanupListeners = () => {
      clearTimeout(timer)
      client.off('relay-connected', onRelay)
    }
    client.on('relay-connected', onRelay)
  })
}

function summarizeProofFailures (reports) {
  return reports
    .slice(0, 5)
    .map((r) => {
      const reason = r.proofReason || r.verifyReason || r.error || 'unknown'
      return r.relay.slice(0, 12) + ':' + reason
    })
    .join(', ')
}

async function collectSampleEntries (drive) {
  const entries = []
  for await (const entry of drive.list('/', { recursive: false })) {
    entries.push(entry)
    if (entries.length >= 5) break
  }
  return entries
}

async function getWithTimeout (drive, path) {
  const buf = await Promise.race([
    drive.get(path),
    new Promise((_, reject) => setTimeout(() =>
      reject(new Error('blob fetch timed out')),
      BLOB_SAMPLE_TIMEOUT_MS))
  ])
  return { key: path, buf }
}

async function verifyHiveRelaySeed (args) {
  const proofStorage = mkdtempSync(join(tmpdir(), 'verify-pin-hiverelay-'))
  const client = new HiveRelayClient(proofStorage)
  const reports = []

  try {
    await client.start()
    await waitForHiveRelay(client, HIVERELAY_TIMEOUT_MS)

    const relays = [...client.relays.keys()]
    console.log('   → HiveRelay proof check (' + relays.length + ' relay connection' + (relays.length === 1 ? '' : 's') + ')...')

    for (const relay of relays) {
      const report = { relay }
      try {
        const proof = await client.proveSeeded(args.key, {
          relay,
          samples: args.samples,
          timeout: HIVERELAY_PROOF_TIMEOUT_MS
        })
        report.proofReason = proof.ok ? null : ((proof.samples || []).map(s => s.reason).find(Boolean) || proof.reason || 'proof-failed')
        if (proof.ok && (args.expect == null || proof.head >= args.expect)) {
          console.log('     ✓ proveSeeded', relay.slice(0, 12) + '…', proof.passed + '/' + proof.total, 'samples at head', proof.head)
          return { mode: 'proveSeeded', relay, head: proof.head, samples: proof.total }
        }
      } catch (err) {
        report.proofReason = err.message
      }

      if (args.verifySeededFallback) {
        try {
          const verdict = await client.verifySeeded(args.key, {
            relay,
            timeout: HIVERELAY_PROOF_TIMEOUT_MS
          })
          report.verifyReason = verdict.complete ? null : 'complete=' + verdict.complete + ',relayFull=' + verdict.relayHasFullLength
          if (verdict.complete && (args.expect == null || verdict.metaLength >= args.expect)) {
            console.log('     ✓ verifySeeded', relay.slice(0, 12) + '…', 'length', verdict.metaLength)
            return { mode: 'verifySeeded', relay, head: verdict.metaLength, samples: 0 }
          }
        } catch (err) {
          report.verifyReason = err.message
        }
      }

      reports.push(report)
    }

    const detail = summarizeProofFailures(reports)
    throw new Error('no connected relay produced a passing seed proof' + (detail ? ' (' + detail + ')' : ''))
  } finally {
    try { await client.destroy() } catch {}
    try { rmSync(proofStorage, { recursive: true, force: true }) } catch {}
  }
}

async function main () {
  await drive.ready()
  console.log('   discoveryKey:', b4a.toString(drive.discoveryKey, 'hex').slice(0, 12) + '…')

  const discovery = swarm.join(drive.discoveryKey, { server: false, client: true })
  process.stdout.write('   → waiting for peers...')

  await Promise.race([
    new Promise((resolve) => swarm.once('connection', () => {
      process.stdout.write(' ✓\n')
      resolve()
    })),
    new Promise((_, reject) => setTimeout(() =>
      reject(new Error(`no peers within ${PEER_TIMEOUT_MS / 1000}s — pin is not being served`)),
      PEER_TIMEOUT_MS))
  ])

  await discovery.flushed().catch(() => {})

  process.stdout.write('   → reading drive length...')
  await Promise.race([
    drive.core.update({ wait: true }),
    new Promise((_, reject) => setTimeout(() =>
      reject(new Error(`couldn't read drive length within ${LENGTH_TIMEOUT_MS / 1000}s`)),
      LENGTH_TIMEOUT_MS))
  ])
  const length = drive.core.length
  process.stdout.write(' length=' + length + '\n')

  if (args.expect != null && length < args.expect) {
    console.log()
    console.error('✗ FAIL — drive length ' + length + ' < expected ' + args.expect)
    console.error('   the release has not propagated to the swarm yet')
    await cleanup(1)
    return
  }

  // Sample one blob to confirm content (not just metadata) replicates
  process.stdout.write('   → sampling a blob...')
  try {
    let sampleResult = null
    const sampleErrors = []
    for (const path of DEFAULT_SAMPLE_PATHS) {
      try {
        sampleResult = await getWithTimeout(drive, path)
        if (sampleResult.buf?.length > 0) break
      } catch (err) {
        sampleErrors.push(path + ': ' + err.message)
      }
      sampleResult = null
    }

    let entries = []
    if (!sampleResult) {
      entries = await Promise.race([
        collectSampleEntries(drive),
        new Promise((_, reject) => setTimeout(() =>
          reject(new Error('blob listing timed out')),
          BLOB_LIST_TIMEOUT_MS))
      ])
    }
    if (!sampleResult) {
      if (entries.length === 0) {
        throw new Error('drive has no top-level entries' + (sampleErrors.length ? ' (' + sampleErrors.join('; ') + ')' : ''))
      }
      const sample = entries.find((e) => e.value?.blob?.byteLength > 0) || entries[0]
      sampleResult = await getWithTimeout(drive, sample.key)
    }
    process.stdout.write(' ✓ ' + (sampleResult.buf?.length || 0) + ' bytes from ' + sampleResult.key + '\n')
  } catch (err) {
    process.stdout.write(' ✗\n')
    console.error('   blob sample failed:', err.message)
    console.error('   metadata is reachable but content blocks are not — partial pin')
    await cleanup(1)
    return
  }

  let hiveRelayProof = null
  if (args.hiverelay) {
    try {
      hiveRelayProof = await verifyHiveRelaySeed(args)
    } catch (err) {
      if (args.requireHiverelay) {
        console.error('   HiveRelay proof failed:', err.message)
        await cleanup(1)
        return
      }
      console.warn('   HiveRelay proof unavailable:', err.message)
      console.warn('   Fresh-peer reachability passed; rerun with --require-hiverelay once storage-proof is enabled on the relay fleet.')
    }
  }

  console.log()
  console.log('✅ Drive is fully reachable')
  console.log('   length:    ' + length)
  console.log('   peers:     ' + peerCount + ' connected during check')
  if (hiveRelayProof) {
    console.log('   relay:     ' + hiveRelayProof.mode + ' via ' + hiveRelayProof.relay.slice(0, 12) + '…')
  }
  console.log()
  console.log('   A Hyperdrive reader can retrieve this content from available peers.')

  await cleanup(0)
}

main().catch(async (err) => {
  console.error()
  console.error('✗ FAIL —', err.message)
  await cleanup(1)
})