OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / scripts / publish-catalog-bee.js

291 lines · javascript ref:
/**
 * Publish an app catalog as a **Hyperbee** (the Pear-native, queryable
 * catalog format) and pin it on the HiveRelay backbone.
 *
 * This is the publisher counterpart to backend/catalog-manager.js
 * `loadCatalogBee()`. The browser already knows how to load Hyperbee
 * catalogs; until now there was no way to *create* one, so the whole
 * bee-catalog path was untestable end-to-end. This closes that gap.
 *
 * The Hyperbee uses the exact key schema loadCatalogBee() reads:
 *   meta!name      → string         (catalog display name)
 *   meta!version   → number         (schema version, currently 1)
 *   app!<id>       → { id, name, description, driveKey|link, version,
 *                      author, categories, publishedAt }
 *
 * Usage:
 *   # New catalog — fresh key, served on the swarm, pinned on relays.
 *   # Storage is a throwaway temp dir unless --storage is given, so to
 *   # be able to UPDATE the catalog later you must pass --storage.
 *   node scripts/publish-catalog-bee.js <catalog.json> [--storage <dir>]
 *
 *   # Update an existing catalog (same key) — reuse its storage dir:
 *   node scripts/publish-catalog-bee.js <catalog.json> --storage <dir>
 *
 *   # Keep serving after pinning (until Ctrl-C) so browsers can pull even
 *   # before relays have fully cached it:
 *   node scripts/publish-catalog-bee.js <catalog.json> --storage <dir> --serve
 *
 *   # Build + serve locally without touching relays (offline testing):
 *   node scripts/publish-catalog-bee.js <catalog.json> --no-pin --serve
 *
 * Output: prints `hyperbee://<key>` — paste it into the Apps-tab catalog
 * field to load it. (The `hyperbee://` scheme tells the browser to use the
 * Hyperbee loader; a bare key / `hyper://` would be read as a Hyperdrive.)
 */

import Corestore from 'corestore'
import Hyperswarm from 'hyperswarm'
import Hyperbee from 'hyperbee'
import { HiveRelayClient } from 'p2p-hiverelay-client'
import { mkdtempSync, existsSync, readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import sodium from 'sodium-universal'
import b4a from 'b4a'
import { normalizeManifest, catalogEntries } from './lib/catalog-bee.js'

// Signed \x00meta manifest — byte-identical construction to the consumer in
// PearBrowser/backend/catalog-manager.js (buildSignedDigest/verifyCatalogMeta).
// Consumers that REQUIRE a signed catalog (mobile's loadSignedCatalogBee) reject
// a bee without it. Anchored to core.key (the trust anchor); signed by the core's
// own writer key. For a corestore manifest core, core.key commits to the writer
// in core.manifest.signers[], so verifying the signer == trusting core.key.
const SIGNED_META_KEY = '\x00meta'
function canonicalize (v) {
  if (Array.isArray(v)) return v.map(canonicalize)
  if (v && typeof v === 'object') {
    const o = {}
    for (const k of Object.keys(v).sort()) o[k] = canonicalize(v[k])
    return o
  }
  return v
}
const canonicalJson = (v) => JSON.stringify(canonicalize(v))
function sha256 (input) {
  const out = b4a.alloc(sodium.crypto_hash_sha256_BYTES)
  sodium.crypto_hash_sha256(out, input)
  return out
}
function buildSignedDigest (anchorKey, metaNoSig) {
  return sha256(b4a.concat([anchorKey, b4a.from(canonicalJson(metaNoSig), 'utf-8')]))
}
async function writeSignedMeta (bee, core, metaNoSig) {
  const anchor = b4a.from(core.key)
  const secret = core.keyPair && core.keyPair.secretKey
  const signerPub = core.keyPair && core.keyPair.publicKey
  if (!secret || !signerPub) throw new Error('core has no writable keyPair — cannot sign \\x00meta')
  const digest = buildSignedDigest(anchor, metaNoSig)
  const sig = b4a.alloc(sodium.crypto_sign_BYTES)
  sodium.crypto_sign_detached(sig, digest, secret)
  if (!sodium.crypto_sign_verify_detached(sig, digest, signerPub)) {
    throw new Error('self-verify of \\x00meta failed — refusing to write')
  }
  await bee.put(SIGNED_META_KEY, { ...metaNoSig, signature: b4a.toString(sig, 'hex') })
  return { signerPub: b4a.toString(signerPub, 'hex') }
}

// Tiny CLI parser — same shape as scripts/publish-and-pin.js (no deps).
function parseArgs (argv) {
  const args = { _: [], flags: {} }
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i]
    if (a.startsWith('--')) {
      const [k, eqv] = a.slice(2).split('=', 2)
      const v = eqv !== undefined ? eqv : (argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true)
      args.flags[k] = v
    } else {
      args._.push(a)
    }
  }
  return args
}

const argv = parseArgs(process.argv.slice(2))
const sourceFile = argv._[0]
const explicitStorage = argv.flags.storage || null
const noPin = argv.flags['no-pin'] === true || argv.flags.pin === false
const serve = argv.flags.serve === true

function usage (msg) {
  if (msg) console.error('error:', msg)
  console.error('usage: node scripts/publish-catalog-bee.js <catalog.json> [--storage <dir>] [--serve] [--no-pin]')
  process.exit(2)
}

if (!sourceFile) usage('missing <catalog.json>')
const srcAbs = resolve(sourceFile)
if (!existsSync(srcAbs)) usage(`catalog file not found: ${srcAbs}`)

// --- Parse + validate the catalog manifest --------------------------------
let normalized
try {
  const manifest = JSON.parse(readFileSync(srcAbs, 'utf-8'))
  normalized = normalizeManifest(manifest)
} catch (err) {
  usage(`could not load ${srcAbs}: ${err.message}`)
}
const { name: catalogName, version: catalogVersion, apps } = normalized

// Persistent storage keeps the catalog key stable across updates; a temp
// dir gives a brand-new key each run (and can't be updated later).
const storage = explicitStorage
  ? resolve(explicitStorage)
  : mkdtempSync(join(tmpdir(), 'catalog-bee-'))

console.log('🍐 Publishing Hyperbee catalog')
console.log('   source:', srcAbs)
console.log('   name:', catalogName, '· version:', catalogVersion, '· apps:', apps.length)
console.log('   storage:', storage, explicitStorage ? '(persistent — updatable)' : '(throwaway — keep --storage to update later)')
console.log()

const SEED_OPTS = {
  replicas: 5,                    // ask 5 relays to pin
  ttlDays: 365,                   // pin for a year
  maxStorage: 64 * 1024 * 1024,   // 64 MB cap (a catalog bee is tiny)
  region: null
}

async function buildBee () {
  const store = new Corestore(storage)
  await store.ready()
  // A named core is deterministic from the store's primaryKey, so the same
  // --storage dir always yields the same catalog key (enables updates).
  const core = store.get({ name: 'catalog' })
  await core.ready()
  const bee = new Hyperbee(core, { keyEncoding: 'utf-8', valueEncoding: 'json' })
  await bee.ready()

  // Sync entries: write meta + apps, and prune app! entries no longer
  // present so updates can remove apps, not just add them.
  const existing = []
  for await (const entry of bee.createReadStream({ gte: 'app!', lt: 'app!~' })) {
    existing.push(entry.key)
  }
  const wanted = new Set(apps.map((a) => `app!${a.id}`))

  const batch = bee.batch()
  for (const key of existing) {
    if (!wanted.has(key)) await batch.del(key)
  }
  for (const [key, value] of catalogEntries(normalized)) {
    await batch.put(key, value)
  }
  await batch.flush()

  // Sign + write the \x00meta manifest so signed-catalog consumers (mobile) accept
  // this bee. Kept in lock-step with the meta!/app! schema above on every publish.
  const { signerPub } = await writeSignedMeta(bee, core, { name: catalogName, version: catalogVersion })
  console.log('   signed \\x00meta — writer pubkey:', signerPub)

  return { store, core, bee }
}

async function pinOnRelays (keyHex) {
  // Separate storage so the relay client doesn't collide with the catalog
  // corestore above. Mirrors scripts/pin-self-on-hiverelay.js.
  const relayStorage = mkdtempSync(join(tmpdir(), 'catalog-bee-pin-'))
  const client = new HiveRelayClient(relayStorage)
  client.on('relay-connected', (info) => {
    const id = (info && info.publicKey) ? Buffer.from(info.publicKey).toString('hex').slice(0, 12) : 'unknown'
    console.log('  + relay connected:', id + '…')
  })
  client.on('seeded', ({ key, acceptances }) => {
    console.log('  ✔ seeded', key.slice(0, 12) + '…', '— ' + acceptances + ' acceptances')
  })
  client.on('seed-error', ({ key, error }) => {
    console.error('  ✗ seed error for', key.slice(0, 12) + '…', '—', error && error.message)
  })

  await client.start()
  console.log('  · relay client started, discovering relays...')

  await new Promise((res) => {
    let connected = 0
    client.on('relay-connected', () => {
      connected += 1
      if (connected >= 1) setTimeout(res, 3000)
    })
    setTimeout(() => {
      if (connected === 0) {
        console.error('  ✗ no relays discovered within 15s — is the DHT reachable?')
        res()
      } else res()
    }, 15000)
  })

  console.log()
  console.log('Broadcasting seed request to ' + client.relays.size + ' relay(s)...')
  let acceptances = []
  try {
    acceptances = await client.seed(keyHex, SEED_OPTS)
  } catch (err) {
    console.error('  ✗ seed() threw:', err.message)
  }
  if (acceptances.length === 0) {
    console.log('⚠️  No relays accepted the seed request (capacity / allowlist / DHT).')
  } else {
    console.log('✅ ' + acceptances.length + ' relay(s) accepted the seed request.')
  }
  return client
}

async function main () {
  const { store, core } = await buildBee()
  const keyHex = Buffer.from(core.key).toString('hex')
  console.log('  · catalog hypercore key:', keyHex)
  console.log('  · bee length (blocks):', core.length)
  console.log()

  // Serve the catalog on Hyperswarm so relays — and browsers — can pull
  // its bytes. Same replication wiring as backend/index.js.
  const swarm = new Hyperswarm()
  swarm.on('connection', (conn) => {
    try { store.replicate(conn) } catch (err) { console.error('  [swarm] replicate failed:', err && err.message) }
  })
  swarm.join(core.discoveryKey, { server: true, client: true })
  await swarm.flush()
  console.log('  · announced on swarm (serving the catalog)')
  console.log()

  let relayClient = null
  if (!noPin) {
    relayClient = await pinOnRelays(keyHex)
    // Linger so relays (and any browser already loading) can pull the few
    // KB of catalog data from us before we tear down.
    console.log()
    console.log('  · lingering 15s so relays can replicate...')
    await new Promise((r) => setTimeout(r, 15000))
  }

  console.log()
  console.log('✅ Catalog published — load it in the Apps tab with:')
  console.log()
  console.log('     hyperbee://' + keyHex)
  console.log()
  if (explicitStorage) {
    console.log('   To update later, edit the JSON and re-run with the same --storage:')
    console.log(`     node scripts/publish-catalog-bee.js ${sourceFile} --storage ${storage}`)
  } else {
    console.log('   ⚠ Throwaway storage — this key cannot be updated. Re-run with')
    console.log('     --storage <dir> if you want a stable, updatable catalog key.')
  }
  console.log()

  if (serve) {
    console.log('  · --serve: staying online to serve the catalog. Ctrl-C to stop.')
    // Keep the process alive indefinitely.
    await new Promise(() => {})
  }

  if (relayClient) { try { await relayClient.destroy() } catch {} }
  try { await swarm.destroy() } catch {}
  try { await store.close() } catch {}
  process.exit(0)
}

main().catch((err) => {
  console.error('fatal:', err && err.stack || err)
  process.exit(1)
})