OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / scripts / publish-app.js

596 lines · javascript ref:
#!/usr/bin/env node

/**
 * Publish a local directory to a Hyperdrive and seed it on live HiveRelay nodes.
 *
 * Automatically reuses the same drive key for the same app (by --name/appId).
 * Subsequent publishes are version updates, not duplicates.
 *
 * Usage:
 *   node scripts/publish-app.js <directory> [options]
 *
 * Options:
 *   --name <name>        App name (also used as appId for deduplication)
 *   --id <id>            Explicit appId (overrides name-derived id)
 *   --desc <description> App description
 *   --version <version>  App version (default: 1.0.0)
 *   --relays <urls>      Comma-separated relay API URLs to seed on
 *   --storage <path>     Corestore path (default: .publisher-storage)
 *   --key <hex>          Explicit drive key (overrides appId lookup)
 *   --bootstrap <nodes>  Comma-separated DHT bootstrap nodes (host:port)
 *   --api-key <key>      Shared operator API key sent to every relay
 *                        (fallback: HIVERELAY_API_KEY env var)
 *   --api-keys <map>     Per-relay keys as url=key,url=key — needed when each
 *                        relay runs its own key (fallback: HIVERELAY_API_KEYS)
 *   --blind              Encrypt content (relay can't read it, P2P only)
 *   --no-stay            Exit after publishing (don't stay online)
 *   --hold-seconds <n>   Seconds to stay online with --no-stay (default: 30)
 *
 * Examples:
 *   # First publish — creates new drive, saves key mapping
 *   node scripts/publish-app.js ../pear-pos/frontend/dist --name "Pear POS"
 *
 *   # Update — reuses same drive key automatically (same --name + --storage)
 *   node scripts/publish-app.js ../pear-pos/frontend/dist --name "Pear POS" --version 1.1.0
 */

import Hyperswarm from 'hyperswarm'
import Corestore from 'corestore'
import Hyperdrive from 'hyperdrive'
import b4a from 'b4a'
import sodium from 'sodium-universal'
import { readdir, readFile, writeFile, stat, mkdir, rename } from 'fs/promises'
import { join, relative, resolve } from 'path'
import { existsSync } from 'fs'

const RELAY_DISCOVERY_TOPIC = b4a.alloc(32)
sodium.crypto_generichash(RELAY_DISCOVERY_TOPIC, b4a.from('hiverelay-discovery-v1'))

// Default relay endpoints — set HIVERELAY_RELAYS env var or pass --relay flags
// Example: HIVERELAY_RELAYS="http://host1:9100,http://host2:9100"
const DEFAULT_RELAYS = process.env.HIVERELAY_RELAYS
  ? process.env.HIVERELAY_RELAYS.split(',').map(s => s.trim())
  : ['http://127.0.0.1:9100']
const DEFAULT_BOOTSTRAP = parseBootstrapNodes(process.env.HIVERELAY_BOOTSTRAP)

function parseArgs (argv) {
  const args = argv.slice(2)
  const opts = {
    directory: null,
    name: null,
    id: null,
    description: null,
    version: '1.0.0',
    relays: DEFAULT_RELAYS,
    storage: '.publisher-storage',
    key: null,
    bootstrap: DEFAULT_BOOTSTRAP,
    apiKey: process.env.HIVERELAY_API_KEY || null,
    apiKeys: parseApiKeyMap(process.env.HIVERELAY_API_KEYS),
    blind: false,
    stay: true,
    holdSeconds: 30
  }

  for (let i = 0; i < args.length; i++) {
    const arg = args[i]
    if (arg === '--name') { opts.name = args[++i]; continue }
    if (arg === '--id') { opts.id = args[++i]; continue }
    if (arg === '--desc') { opts.description = args[++i]; continue }
    if (arg === '--version') { opts.version = args[++i]; continue }
    if (arg === '--relays') { opts.relays = args[++i].split(',').map(s => s.trim()); continue }
    if (arg === '--storage') { opts.storage = args[++i]; continue }
    if (arg === '--key') { opts.key = args[++i]; continue }
    if (arg === '--bootstrap') {
      try {
        opts.bootstrap = parseBootstrapNodes(args[++i])
      } catch (err) {
        console.error('Error: ' + err.message)
        process.exit(1)
      }
      continue
    }
    if (arg === '--api-key') { opts.apiKey = args[++i]; continue }
    if (arg === '--api-keys') {
      try {
        opts.apiKeys = parseApiKeyMap(args[++i])
      } catch (err) {
        console.error('Error: ' + err.message)
        process.exit(1)
      }
      continue
    }
    if (arg === '--blind') { opts.blind = true; continue }
    if (arg === '--encryption-key') {
      const keyVal = args[++i]
      if (!keyVal || !/^[0-9a-f]{64}$/i.test(keyVal)) {
        console.error('Error: --encryption-key must be exactly 64 hex characters (32 bytes)')
        process.exit(1)
      }
      opts.encryptionKey = keyVal
      continue
    }
    if (arg === '--no-stay') { opts.stay = false; continue }
    if (arg === '--hold-seconds') {
      const seconds = Number(args[++i])
      if (!Number.isFinite(seconds) || seconds < 0) {
        console.error('Error: --hold-seconds must be a non-negative number')
        process.exit(1)
      }
      opts.holdSeconds = seconds
      continue
    }
    if (!arg.startsWith('-') && !opts.directory) { opts.directory = arg; continue }
  }

  // --encryption-key without --blind is almost certainly a mistake
  if (opts.encryptionKey && !opts.blind) {
    console.error('Error: --encryption-key requires --blind flag (otherwise content is published unencrypted)')
    process.exit(1)
  }

  return opts
}

/**
 * Parse a per-relay API key map: "http://host1:9100=KEY1,http://host2:9100=KEY2".
 * Split on the FIRST '=' so keys containing '=' (base64 padding) survive.
 * URLs are normalized (trailing slash stripped) for lookup.
 */
function parseApiKeyMap (input) {
  if (!input) return null
  const map = new Map()
  for (const pair of String(input).split(',').map(s => s.trim()).filter(Boolean)) {
    const sep = pair.indexOf('=')
    if (sep <= 0 || sep === pair.length - 1) {
      throw new Error('--api-keys entries must be url=key, got: ' + pair)
    }
    map.set(pair.slice(0, sep).replace(/\/+$/, ''), pair.slice(sep + 1))
  }
  return map.size > 0 ? map : null
}

/**
 * Resolve the API key for one relay URL: per-relay map first (the fleet runs
 * a distinct key per relay), then the shared key as fallback.
 */
function resolveApiKey (relayUrl, opts) {
  const normalized = relayUrl.replace(/\/+$/, '')
  if (opts.apiKeys && opts.apiKeys.has(normalized)) return opts.apiKeys.get(normalized)
  return opts.apiKey || null
}

function deriveAppId (name) {
  return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
}

function parseBootstrapNodes (input) {
  if (!input) return null
  const nodes = String(input)
    .split(',')
    .map(s => s.trim())
    .filter(Boolean)
    .map((value) => {
      const sep = value.lastIndexOf(':')
      if (sep <= 0 || sep === value.length - 1) {
        throw new Error('--bootstrap entries must be host:port')
      }
      const host = value.slice(0, sep)
      const port = Number(value.slice(sep + 1))
      if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) {
        throw new Error('--bootstrap entries must be host:port with a valid port')
      }
      return { host, port }
    })
  return nodes.length ? nodes : null
}

async function loadDriveMap (storagePath) {
  try {
    const mapPath = join(storagePath, 'app-drives.json')
    return JSON.parse(await readFile(mapPath, 'utf8'))
  } catch (_) {
    return {}
  }
}

async function saveDriveMap (storagePath, map) {
  await mkdir(storagePath, { recursive: true })
  const mapPath = join(storagePath, 'app-drives.json')
  await writeFile(mapPath, JSON.stringify(map, null, 2))
}

async function walkDir (dir, base) {
  base = base || dir
  const entries = await readdir(dir, { withFileTypes: true })
  const files = []

  for (const entry of entries) {
    const fullPath = join(dir, entry.name)
    if (entry.isDirectory()) {
      if (entry.name === 'node_modules' || entry.name === '.git') continue
      files.push(...await walkDir(fullPath, base))
    } else if (entry.isFile()) {
      // Skip macOS resource fork files
      if (entry.name.startsWith('._')) continue
      const relPath = '/' + relative(base, fullPath).replace(/\\/g, '/')
      const content = await readFile(fullPath)
      const stats = await stat(fullPath)
      files.push({ path: relPath, content, size: stats.size })
    }
  }

  return files
}

async function loadOrCreateEncryptionKey (storagePath, appId) {
  await mkdir(storagePath, { recursive: true })
  const keyFile = join(storagePath, 'encryption-keys.json')
  let keys = {}
  try {
    const data = await readFile(keyFile, 'utf8')
    keys = JSON.parse(data)
  } catch (err) {
    // Warn if file exists but is corrupted (not just missing)
    if (err.code !== 'ENOENT') {
      console.error(`Warning: ${keyFile} exists but could not be parsed — generating new keys`)
      console.error('  If you had existing blind apps, their encryption keys may be lost')
    }
  }

  const id = appId || '_default'
  if (keys[id]) {
    return b4a.from(keys[id], 'hex')
  }

  // Generate new 32-byte encryption key
  const key = b4a.alloc(32)
  sodium.randombytes_buf(key)
  keys[id] = b4a.toString(key, 'hex')

  // Write with restricted permissions (owner-only) — these are secret keys
  const tmpFile = keyFile + '.tmp'
  await writeFile(tmpFile, JSON.stringify(keys, null, 2), { mode: 0o600 })
  await rename(tmpFile, keyFile)
  return key
}

async function resolveFromRelay (relays, appId) {
  for (const url of relays) {
    try {
      const res = await fetch(url + '/api/resolve/' + encodeURIComponent(appId), { signal: AbortSignal.timeout(5000) })
      if (res.ok) {
        const data = await res.json()
        if (data.driveKey) return data.driveKey
      }
    } catch (_) {}
  }
  return null
}

async function seedOnRelay (relayUrl, appKey, appId, version, blind, apiKey) {
  try {
    const body = { appKey }
    if (appId) body.appId = appId
    if (version) body.version = version
    if (blind) body.blind = true
    const headers = { 'Content-Type': 'application/json' }
    if (apiKey) headers.Authorization = `Bearer ${apiKey}`
    const res = await fetch(relayUrl + '/seed', {
      method: 'POST',
      headers,
      body: JSON.stringify(body)
    })
    let data
    try { data = await res.json() } catch (_) { data = {} }
    return {
      url: relayUrl,
      ok: data.ok || false,
      status: res.status,
      replaced: data.alreadySeeded ? 'same-key' : null,
      canonicalKey: data.canonicalKey || null,
      error: data.error || (res.ok ? null : 'HTTP ' + res.status),
      // 'auth-required' → the relay wants its operator API key; keyed
      // separately so the summary can print an actionable fix.
      authFailure: res.status === 401 || (data.errorCode === 'auth-required'),
      keySent: Boolean(apiKey)
    }
  } catch (err) {
    return { url: relayUrl, ok: false, status: 0, error: err.message, authFailure: false, keySent: Boolean(apiKey) }
  }
}

async function run () {
  const opts = parseArgs(process.argv)

  if (!opts.directory) {
    console.error('Usage: node scripts/publish-app.js <directory> [options]')
    console.error('')
    console.error('Options:')
    console.error('  --name <name>        App name (used for deduplication)')
    console.error('  --id <id>            Explicit appId (overrides name-derived id)')
    console.error('  --desc <description> App description')
    console.error('  --version <version>  App version (default: 1.0.0)')
    console.error('  --storage <path>     Publisher storage path')
    console.error('  --key <hex>          Explicit drive key')
    console.error('  --api-key <key>      Shared operator API key (env: HIVERELAY_API_KEY)')
    console.error('  --api-keys <map>     Per-relay keys url=key,url=key (env: HIVERELAY_API_KEYS)')
    console.error('  --blind              Encrypt content (relay can\'t read, P2P only)')
    console.error('  --encryption-key <h> Use explicit encryption key (hex, for recovery)')
    console.error('  --no-stay            Exit after publishing')
    process.exit(1)
  }

  const dir = resolve(opts.directory)
  if (!existsSync(dir)) {
    console.error('Directory not found:', dir)
    process.exit(1)
  }

  const appId = opts.id || (opts.name ? deriveAppId(opts.name) : null)

  console.log()
  console.log('=== HiveRelay App Publisher ===')
  console.log()
  console.log('  Directory:', dir)
  console.log('  Storage:  ', opts.storage)
  console.log('  Relays:   ', opts.relays.length)
  if (opts.bootstrap) console.log('  Bootstrap:', opts.bootstrap.map(n => `${n.host}:${n.port}`).join(', '))
  if (appId) console.log('  App ID:   ', appId)
  if (opts.version !== '1.0.0') console.log('  Version:  ', opts.version)
  if (opts.blind) console.log('  Mode:      BLIND (encrypted, P2P only)')
  console.log()

  // Scan files
  console.log('  Scanning files...')
  const files = await walkDir(dir)
  const totalSize = files.reduce((sum, f) => sum + f.size, 0)
  console.log('  Found', files.length, 'files (' + (totalSize / 1024 / 1024).toFixed(1) + ' MB)')
  console.log()

  // Boot P2P stack
  console.log('  Starting P2P stack...')
  const store = new Corestore(opts.storage)
  await store.ready()

  const swarm = opts.bootstrap ? new Hyperswarm({ bootstrap: opts.bootstrap }) : new Hyperswarm()
  swarm.on('connection', (conn) => store.replicate(conn))

  // For blind mode: use explicit key, load saved key, or generate new one
  let encryptionKey = null
  if (opts.blind) {
    if (opts.encryptionKey) {
      encryptionKey = b4a.from(opts.encryptionKey, 'hex')
      console.log('  Encryption key: ' + opts.encryptionKey.slice(0, 16) + '... (provided)')
    } else {
      encryptionKey = await loadOrCreateEncryptionKey(opts.storage, appId)
      console.log('  Encryption key: ' + b4a.toString(encryptionKey, 'hex').slice(0, 16) + '... (from storage)')
    }
    console.log('  (share this key with authorized peers for decryption)')
    console.log()
  }

  // Hyperdrive constructor options (with optional encryption)
  const driveOpts = encryptionKey ? { encryptionKey } : {}

  // Resolve drive key: explicit --key > saved mapping > new drive
  let drive
  let isUpdate = false

  if (opts.key) {
    // Explicit key
    console.log('  Reopening drive:', opts.key.slice(0, 16) + '...')
    drive = new Hyperdrive(store, Buffer.from(opts.key, 'hex'), driveOpts)
    isUpdate = true
  } else if (appId) {
    // Check saved appId → key mapping
    const driveMap = await loadDriveMap(opts.storage)
    if (driveMap[appId]) {
      console.log('  Found existing drive for "' + appId + '": ' + driveMap[appId].slice(0, 16) + '...')
      drive = new Hyperdrive(store, Buffer.from(driveMap[appId], 'hex'), driveOpts)
      isUpdate = true
    } else {
      // No local mapping — check relay registry (handles publisher storage loss)
      // Skip relay recovery for blind apps unless --encryption-key was explicitly provided
      // (a new publisher instance generates a different encryption key, can't write to old drive)
      const canRecover = !opts.blind || opts.encryptionKey
      if (canRecover) {
        const resolvedKey = await resolveFromRelay(opts.relays, appId)
        if (resolvedKey) {
          console.log('  Relay registry has key for "' + appId + '": ' + resolvedKey.slice(0, 16) + '...')
          console.log('  (recovered from relay — publisher storage was likely lost)')
          drive = new Hyperdrive(store, Buffer.from(resolvedKey, 'hex'), driveOpts)
          isUpdate = true
          // Save locally so we don't need relay lookup next time
          const map = await loadDriveMap(opts.storage)
          map[appId] = resolvedKey
          await saveDriveMap(opts.storage, map)
        }
      } else if (opts.blind) {
        console.log('  Blind mode: creating new drive (use --encryption-key to recover existing)')
      }
    }
  }

  if (!drive) {
    console.log('  Creating new Hyperdrive...')
    drive = new Hyperdrive(store, null, driveOpts)
  }

  await drive.ready()

  const driveKey = b4a.toString(drive.key, 'hex')
  console.log('  Drive key:', driveKey)
  console.log('  Mode:     ', isUpdate ? 'UPDATE (same key, new version)' : 'NEW DRIVE')
  console.log()

  // Save the appId → key mapping for future publishes
  if (appId) {
    const driveMap = await loadDriveMap(opts.storage)
    driveMap[appId] = driveKey
    await saveDriveMap(opts.storage, driveMap)
  }

  // Build manifest.json
  const manifest = {
    id: appId || driveKey.slice(0, 12),
    name: opts.name || 'Unknown App',
    description: opts.description || (opts.name ? opts.name + ' — published via HiveRelay' : ''),
    version: opts.version,
    main: '/index.html',
    files: files.length,
    totalBytes: totalSize,
    publishedAt: new Date().toISOString()
  }

  // Check if manifest.json already exists in the dist (user-provided)
  const hasUserManifest = files.some(f => f.path === '/manifest.json')
  if (hasUserManifest) {
    // Merge user's manifest with our fields (user fields take priority)
    try {
      const userManifest = JSON.parse(files.find(f => f.path === '/manifest.json').content.toString())
      Object.assign(manifest, userManifest)
      // But always ensure id is set
      if (!manifest.id) manifest.id = appId || driveKey.slice(0, 12)
      console.log('  Using user-provided manifest.json (merged with publisher metadata)')
    } catch (_) {
      console.log('  Warning: could not parse user manifest.json, using generated one')
    }
  }

  await drive.put('/manifest.json', b4a.from(JSON.stringify(manifest, null, 2)))
  console.log('  Wrote manifest.json (id: ' + manifest.id + ', version: ' + manifest.version + ')')

  // Write all files
  console.log('  Writing files to Hyperdrive...')
  let written = 0
  for (const file of files) {
    if (file.path === '/manifest.json') { written++; continue } // already written
    await drive.put(file.path, file.content)
    written++
    if (written % 10 === 0 || written === files.length) {
      process.stdout.write('\r  Progress: ' + written + '/' + files.length)
    }
  }
  console.log('\n  All files written. Drive version:', drive.version)
  console.log()

  // Join DHT
  console.log('  Joining DHT...')
  swarm.join(drive.discoveryKey, { server: true, client: true })
  swarm.join(RELAY_DISCOVERY_TOPIC, { server: true, client: false })
  await swarm.flush()
  console.log('  Announced on DHT.')
  console.log()

  // Seed on relays (pass appId + version for deduplication)
  console.log('  Seeding on relays...')
  const seedResults = await Promise.all(
    opts.relays.map(url => seedOnRelay(url, driveKey, manifest.id, manifest.version, opts.blind, resolveApiKey(url, opts)))
  )

  let seeded = 0
  const authFailures = []
  for (const result of seedResults) {
    let status = result.ok ? 'OK' : 'FAIL: ' + result.error
    if (result.replaced) status += ' (already seeded)'
    if (result.canonicalKey) status += ' [canonical: ' + result.canonicalKey.slice(0, 16) + '...]'
    console.log('    ' + result.url + ' — ' + status)
    if (result.ok) seeded++
    if (result.authFailure) authFailures.push(result)
  }
  console.log()
  console.log('  Seeded on', seeded + '/' + opts.relays.length, 'relays')
  console.log()

  if (authFailures.length > 0) {
    console.error('  ┌─────────────────────────────────────────────────────────────────┐')
    console.error('  │  AUTH FAILURE: ' + authFailures.length + '/' + opts.relays.length + ' relay(s) returned 401 Unauthorized.            │')
    console.error('  └─────────────────────────────────────────────────────────────────┘')
    console.error('  /seed requires each relay\'s operator API key. Refused by:')
    for (const f of authFailures) {
      console.error('    ' + f.url + (f.keySent ? '  (a key WAS sent — wrong key for this relay?)' : '  (no key sent)'))
    }
    console.error()
    console.error('  Fix — per-relay keys (fleets run a distinct key per relay):')
    console.error('    --api-keys "http://relay1:9100=KEY1,http://relay2:9100=KEY2"')
    console.error('    or env HIVERELAY_API_KEYS in the same url=key,url=key format.')
    console.error('  Or a single shared key: --api-key KEY / env HIVERELAY_API_KEY.')
    console.error()
  }
  if (seeded === 0 && opts.relays.length > 0) {
    console.error('  WARNING: no relay accepted this publish — the app is NOT seeded anywhere.')
    console.error()
    process.exitCode = 1
  }

  // Print access info
  if (opts.blind) {
    console.log('  === BLIND / ENCRYPTED APP ===')
    console.log()
    const ekHex = b4a.toString(encryptionKey, 'hex')
    console.log('    Drive key:       ', driveKey)
    console.log('    Encryption key:  ', ekHex.slice(0, 8) + '...' + ekHex.slice(-8) + '  (use --show-key for full key)')
    if (process.argv.includes('--show-key')) {
      console.log('    Full enc key:    ', ekHex)
    }
    console.log()
    console.log('    Access: P2P only (PearBrowser / Hyperswarm)')
    console.log('    HTTP gateway:    BLOCKED (relay has ciphertext only)')
    console.log()
    console.log('    Share BOTH keys with authorized users:')
    console.log('      Drive key = which app to open')
    console.log('      Encryption key = how to decrypt it')
    console.log()
    console.log('    Catalog: https://relay-us.p2phiverelay.xyz/catalog.json')
    console.log()
  } else {
    console.log('  === Access URLs ===')
    console.log()
    for (const url of opts.relays) {
      console.log('    ' + url + '/v1/hyper/' + driveKey + '/index.html')
    }
    console.log()
    console.log('    Gateway: https://relay-us.p2phiverelay.xyz/v1/hyper/' + driveKey + '/index.html')
    console.log()
    console.log('    Catalog: https://relay-us.p2phiverelay.xyz/catalog.json')
    console.log()
  }

  // Monitor replication
  let peerCount = 0
  swarm.on('connection', () => {
    peerCount++
    console.log('  [' + new Date().toISOString().slice(11, 19) + '] Peer connected (total: ' + peerCount + ')')
  })
  swarm.on('connection', (conn) => {
    conn.on('close', () => { peerCount-- })
  })

  if (opts.stay) {
    console.log('  Publisher staying online for replication. Ctrl+C to exit.')
    console.log()

    setInterval(() => {
      console.log('  [' + new Date().toISOString().slice(11, 19) + '] Peers: ' + swarm.connections.size + ', Drive version: ' + drive.version)
    }, 30000)

    process.on('SIGINT', async () => {
      console.log('\n  Shutting down publisher...')
      await swarm.destroy()
      await store.close()
      process.exit(0)
    })
  } else {
    console.log('  Waiting ' + opts.holdSeconds + 's for initial replication...')
    await new Promise(resolve => setTimeout(resolve, opts.holdSeconds * 1000))
    await swarm.destroy()
    await store.close()
  }
}

run().catch(err => {
  console.error('Fatal:', err)
  process.exit(1)
})