OverviewFilesCommitsIssuesDocsReleasesPeers

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

227 lines · javascript ref:
/**
 * HiveRelay CLI — `catalog` subcommand surface.
 *
 * Operator-facing wrappers around the relay's /api/manage/catalog/* HTTP
 * endpoints. Each function makes one HTTP call against the running relay,
 * pretty-prints the response (or a one-line success/error), and returns
 * an exit code (0 for success, 1 for failure) — the caller chooses
 * whether to actually call process.exit.
 *
 * Auth: pass `apiKey` (resolved from --api-key flag or HIVERELAY_API_KEY).
 * Network: pass an explicit `fetchImpl` for testing; falls back to
 * globalThis.fetch.
 */

import { isValidHexKey as isSharedValidHexKey } from '../core/constants.js'

const VALID_MODES = ['open', 'review', 'allowlist', 'closed']

export const CATALOG_HELP = `Usage:
  hiverelay catalog mode <open|review|allowlist|closed>
  hiverelay catalog allowlist <pubkey>[,<pubkey>...]    # replaces the list
  hiverelay catalog approve <appKey>
  hiverelay catalog reject <appKey>
  hiverelay catalog remove <appKey>
  hiverelay catalog pending                              # lists current queue

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 isValidHexKey (value, length = 64) {
  return isSharedValidHexKey(value, length)
}

export function normalizeApiUrl (value) {
  const raw = String(value || '').trim()
  if (!raw) return ''
  if (!(raw.startsWith('http://') || raw.startsWith('https://'))) return ''
  return raw.replace(/\/+$/, '')
}

export function resolveApiUrl (argv) {
  const supplied = argv['api-url']
  if (supplied !== undefined) {
    const normalized = normalizeApiUrl(supplied)
    if (!normalized) {
      throw new Error('--api-url must start with http:// or https://')
    }
    return normalized
  }
  return 'http://127.0.0.1:9100'
}

export function resolveApiKey (argv, env = process.env) {
  const flag = typeof argv['api-key'] === 'string' ? argv['api-key'].trim() : ''
  return flag || env.HIVERELAY_API_KEY || null
}

export function parseAllowlist (raw) {
  if (raw === undefined || raw === null) return []
  return []
    .concat(raw)
    .flatMap(v => String(v).split(','))
    .map(v => v.trim())
    .filter(Boolean)
}

/**
 * Build the HTTP request descriptor for a given catalog subcommand and
 * positional arguments. Returns `{ method, path, body }` or throws an
 * Error with a human-readable message describing the validation failure.
 */
export function buildCatalogRequest (subcommand, positional, argv = {}) {
  switch (subcommand) {
    case 'mode': {
      const mode = positional[0]
      if (!VALID_MODES.includes(mode)) {
        throw new Error('mode must be one of: ' + VALID_MODES.join(', '))
      }
      return { method: 'POST', path: '/api/manage/catalog/mode', body: { mode } }
    }
    case 'allowlist': {
      const list = parseAllowlist(positional[0])
      if (list.length === 0) {
        throw new Error('allowlist requires at least one pubkey (comma-separated)')
      }
      for (const k of list) {
        if (!isValidHexKey(k, 64)) {
          throw new Error('allowlist entries must be 64 hex characters: ' + k)
        }
      }
      return { method: 'POST', path: '/api/manage/catalog/allowlist', body: { allowlist: list } }
    }
    case 'approve':
    case 'reject':
    case 'remove': {
      const appKey = positional[0]
      if (!isValidHexKey(appKey, 64)) {
        throw new Error('appKey must be 64 hex characters')
      }
      return { method: 'POST', path: '/api/manage/catalog/' + subcommand, body: { appKey } }
    }
    case 'pending': {
      return { method: 'GET', path: '/api/manage/catalog/pending', body: null }
    }
    default:
      throw new Error('Unknown catalog subcommand: ' + subcommand)
  }
}

export async function performRequest ({ apiUrl, apiKey, method, path, body, fetchImpl }) {
  const fetchFn = fetchImpl || globalThis.fetch
  if (typeof fetchFn !== 'function') {
    throw new Error('fetch is not available — Node 18+ required')
  }
  const headers = { Accept: 'application/json' }
  if (apiKey) headers.Authorization = 'Bearer ' + apiKey
  const init = { method, headers }
  if (body !== null && body !== undefined) {
    headers['Content-Type'] = 'application/json'
    init.body = JSON.stringify(body)
  }

  let res
  try {
    res = await fetchFn(apiUrl + path, init)
  } catch (err) {
    throw new Error(`cannot reach relay ${apiUrl}: ${err.message}`)
  }
  const text = await res.text()
  let payload = null
  if (text) {
    try { payload = JSON.parse(text) } catch (_) { payload = { raw: text } }
  }
  if (!res.ok) {
    const reason = payload && payload.error ? payload.error : `${res.status} ${res.statusText}`
    const err = new Error(reason)
    err.status = res.status
    err.payload = payload
    throw err
  }
  return payload || {}
}

export function formatPending (payload) {
  if (!payload || !Array.isArray(payload.requests) || payload.requests.length === 0) {
    return `No pending catalog requests (mode: ${payload?.mode || 'unknown'}).`
  }
  const lines = []
  lines.push(`Pending requests: ${payload.count} (mode: ${payload.mode || 'unknown'})`)
  for (const r of payload.requests) {
    const appKey = r.appKey ? r.appKey.slice(0, 16) + '...' : 'unknown'
    const publisher = r.publisherPubkey ? r.publisherPubkey.slice(0, 12) + '...' : 'unknown'
    const ts = r.discoveredAt ? new Date(r.discoveredAt).toISOString() : 'n/a'
    const tier = r.privacyTier || 'unknown'
    lines.push(`  ${appKey}  publisher:${publisher}  tier:${tier}  at:${ts}`)
  }
  return lines.join('\n')
}

/**
 * Top-level dispatcher for `hiverelay catalog <subcommand> ...`.
 * Returns a numeric exit code and emits output via `out`/`err` writers.
 */
export async function runCatalogCommand ({
  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(CATALOG_HELP)
    return subcommand ? 0 : 0
  }

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

  let request
  try {
    request = buildCatalogRequest(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('catalog ' + subcommand + ' failed: ' + e.message)
    return 1
  }

  if (subcommand === 'pending') {
    out(formatPending(payload))
    return 0
  }

  // Write subcommands: short success line.
  if (subcommand === 'mode') {
    out(`OK — catalog mode set to ${payload.mode || request.body.mode}`)
  } else if (subcommand === 'allowlist') {
    const len = (payload.allowlist || request.body.allowlist).length
    out(`OK — allowlist replaced (${len} pubkey${len === 1 ? '' : 's'})`)
  } else if (subcommand === 'approve') {
    out('OK — approved ' + request.body.appKey.slice(0, 16) + '...')
  } else if (subcommand === 'reject') {
    out('OK — rejected ' + request.body.appKey.slice(0, 16) + '...')
  } else if (subcommand === 'remove') {
    out('OK — removed ' + request.body.appKey.slice(0, 16) + '...')
  }
  return 0
}