OverviewFilesCommitsIssuesDocsReleasesPeers

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

108 lines · javascript ref:
// Pure helpers for the Hyperbee catalog format — shared by
// scripts/publish-catalog-bee.js and its test. Framework-free and
// side-effect-free (no I/O), so the schema contract can be unit-tested
// against a real Hyperbee without relays or the swarm.
//
// The key schema MUST stay in lock-step with backend/catalog-manager.js
// loadCatalogBee(), which reads:
//   bee.get('meta!name'), bee.get('meta!version')
//   bee.createReadStream({ gte: 'app!', lt: 'app!~' })

import safetyMod from '../../backend/catalog-safety.cjs'

const { normalizeCatalogApp, normalizeCatalogData } = safetyMod
const APP_RANGE = { gte: 'app!', lt: 'app!~' }

/**
 * Validate + normalize a catalog manifest ({ name, version, apps[] }).
 * Throws Error on invalid input; returns a clean { name, version, apps }.
 *
 * `now` is injectable so callers (and tests) control the publishedAt
 * default deterministically.
 */
export function normalizeManifest (manifest, now = Date.now()) {
  if (!manifest || typeof manifest !== 'object') throw new Error('catalog must be a JSON object')

  const name = typeof manifest.name === 'string' && manifest.name.trim()
    ? manifest.name.trim()
    : 'P2P Catalog'
  const version = Number.isInteger(manifest.version) ? manifest.version : 1

  // An empty apps[] is allowed: a community catalogue starts empty and fills as
  // submissions are approved (the meta!name/version still publish, so the bee
  // exists + is loadable). Only a non-array apps is an error.
  if (!Array.isArray(manifest.apps)) throw new Error('catalog apps must be an array')
  const rawApps = manifest.apps

  const apps = []
  const seenIds = new Set()
  for (const [i, app] of rawApps.entries()) {
    if (!app || typeof app !== 'object') throw new Error(`apps[${i}] is not an object`)
    const clean = normalizeCatalogApp(app, { source: 'hyperbee' })
    const id = clean && clean.id ? clean.id : ''
    if (!id) throw new Error(`apps[${i}] needs an id (or driveKey/link to derive one)`)
    if (seenIds.has(id)) throw new Error(`duplicate app id: ${id}`)
    seenIds.add(id)
    const entry = {
      id,
      name: clean.name || id,
      description: clean.description || '',
      driveKey: clean.driveKey || '',
      link: clean.link || '',
      version: clean.version || '',
      author: clean.author || '',
      categories: clean.categories || [],
      verification: clean.verification || 'unverified',
      publishedAt: Number.isFinite(app.publishedAt) ? app.publishedAt : now
    }
    if (typeof clean.homepage === 'string' && clean.homepage) entry.homepage = clean.homepage
    if (typeof clean.sourceUrl === 'string' && clean.sourceUrl) entry.sourceUrl = clean.sourceUrl
    if (typeof clean.license === 'string' && clean.license) entry.license = clean.license
    if (clean.type === 'standalone' || clean.type === 'hypersite') entry.type = clean.type
    if (typeof clean.legacyMigrationId === 'string' && clean.legacyMigrationId) entry.legacyMigrationId = clean.legacyMigrationId
    if (clean.nativeDelivery) entry.nativeDelivery = clean.nativeDelivery
    // Inline icon (data: URI) — carried so apps WITHOUT a fetchable drive icon
    // (pear://-only apps, or drives lacking /icon.*) still render a real icon.
    if (typeof clean.iconData === 'string' && clean.iconData) entry.iconData = clean.iconData
    apps.push(entry)
  }
  return { name, version, apps }
}

/**
 * The exact (key, value) pairs to write for a normalized manifest, in the
 * schema loadCatalogBee() reads back. Pure — the caller does the I/O.
 */
export function catalogEntries (normalized) {
  const entries = [
    ['meta!name', normalized.name],
    ['meta!version', normalized.version]
  ]
  for (const app of normalized.apps) entries.push([`app!${app.id}`, app])
  return entries
}

/**
 * Mirror of loadCatalogBee()'s read path, factored out so a test can prove
 * round-trip equivalence and the publisher can sanity-check its own write.
 * Reads a ready Hyperbee and returns the same `data` shape the browser
 * builds.
 */
export async function readCatalogBee (bee, keyHex = '') {
  const apps = []
  for await (const entry of bee.createReadStream(APP_RANGE)) {
    if (entry.value && typeof entry.value === 'object') apps.push(entry.value)
  }
  const nameEntry = await bee.get('meta!name').catch(() => null)
  const versionEntry = await bee.get('meta!version').catch(() => null)
  const data = normalizeCatalogData({
    version: versionEntry ? versionEntry.value : 1,
    name: nameEntry ? nameEntry.value : 'P2P Catalog',
    source: 'hyperbee',
    sourceKey: keyHex,
    apps
  }, { source: 'hyperbee' })
  data.count = { total: data.apps.length, apps: data.apps.length }
  return data
}