OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / core / cli / federation.js

189 lines · javascript ref:
/**
 * HiveRelay CLI — `federation` subcommand surface.
 *
 * Operator-facing wrappers around the relay's /api/manage/federation/*
 * HTTP endpoints. Pure helpers + a top-level dispatcher; no side effects
 * on import.
 */

import {
  resolveApiUrl, resolveApiKey, performRequest, isValidHexKey
} from './catalog.js'

export const FEDERATION_HELP = `Usage:
  hiverelay federation list
  hiverelay federation follow <url> [--pubkey <hex>]
  hiverelay federation mirror <url> [--pubkey <hex>]
  hiverelay federation unfollow <url>
  hiverelay federation republish <appKey> --source <url> [--channel <name>] [--note <text>]
  hiverelay federation unrepublish <appKey>

Options:
  --api-url <url>      Relay API base URL (default: http://127.0.0.1:9100)
  --api-key <key>      API key (or use HIVERELAY_API_KEY env)
`

export function isValidUrl (value) {
  if (typeof value !== 'string') return false
  const v = value.trim()
  return v.startsWith('http://') || v.startsWith('https://')
}

/**
 * Build the HTTP request descriptor for a given federation subcommand.
 * Throws Error on validation failure.
 */
export function buildFederationRequest (subcommand, positional, argv = {}) {
  switch (subcommand) {
    case 'list': {
      return { method: 'GET', path: '/api/manage/federation', body: null }
    }
    case 'follow':
    case 'mirror': {
      const url = positional[0]
      if (!isValidUrl(url)) throw new Error('url must start with http:// or https://')
      const body = { url: url.trim() }
      if (argv.pubkey !== undefined) {
        if (!isValidHexKey(String(argv.pubkey), 64)) {
          throw new Error('--pubkey must be 64 hex characters')
        }
        body.pubkey = String(argv.pubkey)
      }
      return { method: 'POST', path: '/api/manage/federation/' + subcommand, body }
    }
    case 'unfollow': {
      const url = positional[0]
      if (!isValidUrl(url)) throw new Error('url must start with http:// or https://')
      return {
        method: 'POST',
        path: '/api/manage/federation/unfollow',
        body: { url: url.trim() }
      }
    }
    case 'republish': {
      const appKey = positional[0]
      if (!isValidHexKey(appKey, 64)) {
        throw new Error('appKey must be 64 hex characters')
      }
      const sourceUrl = argv.source
      if (!isValidUrl(sourceUrl)) {
        throw new Error('--source must be set to a URL starting with http:// or https://')
      }
      const body = { appKey, sourceUrl: String(sourceUrl).trim() }
      if (argv.pubkey !== undefined) {
        if (!isValidHexKey(String(argv.pubkey), 64)) {
          throw new Error('--pubkey must be 64 hex characters')
        }
        body.sourcePubkey = String(argv.pubkey)
      }
      if (argv.channel !== undefined) body.channel = String(argv.channel)
      if (argv.note !== undefined) body.note = String(argv.note)
      return { method: 'POST', path: '/api/manage/federation/republish', body }
    }
    case 'unrepublish': {
      const appKey = positional[0]
      if (!isValidHexKey(appKey, 64)) {
        throw new Error('appKey must be 64 hex characters')
      }
      return {
        method: 'POST',
        path: '/api/manage/federation/unrepublish',
        body: { appKey }
      }
    }
    default:
      throw new Error('Unknown federation subcommand: ' + subcommand)
  }
}

export function formatFederationList (payload) {
  if (!payload || typeof payload !== 'object') return 'No federation data.'
  const lines = []
  const subscriptions = Array.isArray(payload.subscriptions) ? payload.subscriptions : []
  const republishes = Array.isArray(payload.republishes) ? payload.republishes : []

  lines.push(`Federation subscriptions: ${subscriptions.length}`)
  if (subscriptions.length === 0) {
    lines.push('  (none)')
  } else {
    for (const s of subscriptions) {
      const pub = s.pubkey ? s.pubkey.slice(0, 12) + '...' : 'no-pubkey'
      lines.push(`  [${s.mode || 'follow'}] ${s.url}  pubkey:${pub}`)
    }
  }

  lines.push('')
  lines.push(`Republishes: ${republishes.length}`)
  if (republishes.length === 0) {
    lines.push('  (none)')
  } else {
    for (const r of republishes) {
      const appKey = r.appKey ? r.appKey.slice(0, 16) + '...' : 'unknown'
      const src = r.sourceUrl || 'no-source'
      const channel = r.channel ? `  channel:${r.channel}` : ''
      lines.push(`  ${appKey}  source:${src}${channel}`)
    }
  }
  return lines.join('\n')
}

export async function runFederationCommand ({
  argv,
  positional,
  env = process.env,
  fetchImpl,
  out = (msg) => console.log(msg),
  err = (msg) => console.error(msg)
}) {
  const subcommand = positional[0]
  if (!subcommand || subcommand === 'help' || argv.help) {
    out(FEDERATION_HELP)
    return 0
  }

  let apiUrl
  try {
    apiUrl = resolveApiUrl(argv)
  } catch (e) {
    err(e.message)
    return 1
  }
  const apiKey = resolveApiKey(argv, env)

  let request
  try {
    request = buildFederationRequest(subcommand, positional.slice(1), argv)
  } catch (e) {
    err(e.message)
    return 1
  }

  let payload
  try {
    payload = await performRequest({
      apiUrl, apiKey, method: request.method, path: request.path, body: request.body, fetchImpl
    })
  } catch (e) {
    err('federation ' + subcommand + ' failed: ' + e.message)
    return 1
  }

  if (subcommand === 'list') {
    out(formatFederationList(payload))
    return 0
  }

  if (subcommand === 'follow') {
    out('OK — following ' + request.body.url)
  } else if (subcommand === 'mirror') {
    out('OK — mirroring ' + request.body.url)
  } else if (subcommand === 'unfollow') {
    out('OK — unfollowed ' + request.body.url + (payload && payload.removed ? '' : ' (no-op)'))
  } else if (subcommand === 'republish') {
    out('OK — republish set for ' + request.body.appKey.slice(0, 16) + '... from ' + request.body.sourceUrl)
  } else if (subcommand === 'unrepublish') {
    out('OK — unrepublish ' + request.body.appKey.slice(0, 16) + '...' + (payload && payload.removed ? '' : ' (no-op)'))
  }
  return 0
}