OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / routes / federation.js

526 lines · javascript ref:
/**
 * Federation Routes — Server-to-Server Linking
 *
 * Layer 1: Infrastructure. Connect servers. Full read access.
 * Storage: Local Hyperbee only (federation:{serverKey}).
 * Effect: Symmetric — both servers get full read of everything.
 *
 * Handles:
 * - POST   /api/federation/invite     Create a server invite
 * - POST   /api/federation/join       Join via invite hex
 * - GET    /api/federation/peers      List federated servers
 * - PATCH  /api/federation/peers/:key Update a federated server's metadata
 * - DELETE /api/federation/peers/:key Revoke a federated server
 * - POST   /api/repos/:name/sync     Clone a repo from a remote server
 * - PATCH  /api/sync/:repoName       Set sync preference for a repo
 * - GET    /api/sync/:repoName       Get sync preference for a repo
 * - GET    /api/sync                  List all sync preferences
 */

import { json, badRequest, notFound, readJson } from '../response.js'
import { requireAdmin } from '../middleware/admin.js'
import { requireAuth } from '../middleware/require-auth.js'
import { RepoLinker } from '../repo-link/repo-linker.js'
import { spawn } from 'bare-subprocess'
import path from 'bare-path'
import os from 'bare-os'

/**
 * Build the best HTTP URL for this server (LAN-accessible).
 * Prefers a non-localhost bind address.
 */
function buildHttpUrl (server) {
  const { port, pathPrefix, bindAddresses } = server.config
  const prefix = pathPrefix || ''
  // Pick the first non-localhost bind address, or fall back to localhost
  let host = 'localhost'
  if (bindAddresses && bindAddresses.length > 0) {
    const lanAddr = bindAddresses.find(a => a !== 'localhost' && a !== '127.0.0.1' && a !== '::1')
    host = lanAddr || bindAddresses[0]
    // 0.0.0.0 binds all interfaces but isn't routable — use hostname
    if (host === '0.0.0.0' || host === '::') {
      try { host = os.hostname() } catch { host = 'localhost' }
    }
  }
  return `http://${host}:${port || 5005}${prefix}`
}

/**
 * Register federation routes
 */
export function registerFederationRoutes (router) {
  router.post('/api/federation/invite', requireAdmin, handleFederationInvite)
  router.post('/api/federation/join', requireAdmin, handleFederationJoin)
  router.get('/api/federation/peers', handleFederationPeers)
  router.get('/api/federation/peers/:key/catalog', handlePeerCatalog)
  router.patch('/api/federation/peers/:key', requireAdmin, handleFederationPeerUpdate)
  router.delete('/api/federation/peers/:key', requireAdmin, handleFederationRevoke)
  router.post('/api/repos/:name/sync', requireAuth, handleRepoSync)
  router.patch('/api/sync/:repoName', requireAdmin, handleSyncUpdate)
  router.get('/api/sync/:repoName', handleSyncGet)
  router.get('/api/sync', handleSyncList)
}

/**
 * POST /api/federation/invite
 * Create a blind-pairing invite for server federation.
 * The invite payload includes the server's identity key.
 */
async function handleFederationInvite (req, res, server) {
  try {
    const { serverIdentity } = server.p2p
    const { registry } = server.coreData

    const serverKey = serverIdentity
      ? serverIdentity.getPublicKeyHex()
      : null

    if (!serverKey) {
      return badRequest(res, 'Server identity not initialized — P2P not enabled')
    }

    // Unified MetadataBase key — single key for all shared metadata
    const metadataKey = server.metadataBase
      ? server.metadataBase.bootstrapKeyHex
      : null

    // Build this server's HTTP URL for clone operations
    const httpUrl = buildHttpUrl(server)

    const linker = new RepoLinker({ bootstrap: server._bootstrap })
    const result = await linker.createInvite({
      type: 'federation',
      serverKey,
      // Send MetadataBase key as both registry and deploy keys for backward compat
      registryBootstrapKey: metadataKey,
      deployRegistryKey: metadataKey,
      metadataKey,
      httpUrl
    })

    // Handle peer join in background
    result.waitForPeer().then(async (joinerInfo) => {
      if (joinerInfo && joinerInfo.serverKey) {
        // Store federation record in local Hyperbee
        await registry.put(`federation:${joinerInfo.serverKey}`, {
          serverKey: joinerInfo.serverKey,
          displayName: joinerInfo.displayName || null,
          httpUrl: joinerInfo.httpUrl || null,
          linkedAt: Date.now(),
          lastSeenAt: Date.now(),
          status: 'active'
        })
        console.log(`[Federation] Server linked: ${joinerInfo.serverKey.slice(0, 16)}...`)

        // If we have a namespace registry, add them as a writer
        if (server.namespaceRegistry && joinerInfo.serverKey) {
          const peerId = joinerInfo.peerId || joinerInfo.serverKey.slice(0, 16)
          try {
            // DANGER: serverKey bug — passes Ed25519 identity key, not Hypercore key.
            await server.namespaceRegistry.addWriter(peerId, joinerInfo.serverKey, 'writer')
          } catch (err) {
            console.error('[Federation] Failed to add registry writer:', err.message)
          }
        }
      }
    }).catch(() => {
      linker.close().catch(() => {})
    })

    json(res, {
      invite: result.inviteHex,
      serverKey
    })
  } catch (err) {
    console.error('[Federation] Invite error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * POST /api/federation/join
 * Join a federated server via invite hex.
 */
async function handleFederationJoin (req, res, server) {
  const { serverIdentity } = server.p2p
  const { registry } = server.coreData
  const { defaultNamespace } = server.config
  const body = await readJson(req)
  const { invite } = body

  if (!invite) return badRequest(res, 'invite is required')

  const serverKey = serverIdentity
    ? serverIdentity.getPublicKeyHex()
    : null

  if (!serverKey) {
    return badRequest(res, 'Server identity not initialized — P2P not enabled')
  }

  const linker = new RepoLinker({ bootstrap: server._bootstrap })

  try {
    // Build this server's HTTP URL
    const httpUrl = buildHttpUrl(server)

    const payload = await linker.joinWithInvite(invite, {
      serverKey,
      peerId: serverKey.slice(0, 16),
      displayName: defaultNamespace || null,
      httpUrl
    })

    if (!payload.serverKey) {
      return badRequest(res, 'Invalid invite — no server key received')
    }

    // Store federation record in local Hyperbee
    await registry.put(`federation:${payload.serverKey}`, {
      serverKey: payload.serverKey,
      displayName: payload.displayName || null,
      httpUrl: payload.httpUrl || null,
      linkedAt: Date.now(),
      lastSeenAt: Date.now(),
      status: 'active'
    })

    // Join the remote's MetadataBase if they have one and ours differs
    const remoteMetadataKey = payload.metadataKey || payload.registryBootstrapKey || payload.deployRegistryKey
    if (remoteMetadataKey && server.metadataBase) {
      const localKey = server.metadataBase.bootstrapKeyHex
      if (localKey !== remoteMetadataKey) {
        console.log(`[Federation] Joining remote MetadataBase: ${remoteMetadataKey.slice(0, 16)}...`)
        await server.metadataBase.joinKey(remoteMetadataKey)

        // Persist the shared key
        await registry.put('config:metadataBase', {
          bootstrapKey: remoteMetadataKey,
          sharedFrom: payload.serverKey,
          joinedAt: Date.now()
        })

        // Join driveSwarm with new discovery key for replication
        const { driveSwarm } = server.p2p
        if (server.metadataBase.discoveryKey && driveSwarm) {
          driveSwarm.join(server.metadataBase.discoveryKey)
        }

        console.log(`[Federation] MetadataBase switched — data will replicate from peer`)
      } else {
        console.log(`[Federation] Already on same MetadataBase: ${localKey.slice(0, 16)}`)
      }
    }

    json(res, {
      success: true,
      serverKey: payload.serverKey,
      metadataKey: server.metadataBase?.bootstrapKeyHex || null
    })
  } catch (err) {
    console.error('[Federation] Join error:', err)
    json(res, { error: err.message }, 500)
  } finally {
    await linker.close()
  }
}

/**
 * GET /api/federation/peers
 * List all federated servers.
 */
async function handleFederationPeers (req, res, server) {
  const { registry } = server.coreData
  const { serverIdentity } = server.p2p
  try {
    const peers = []
    for await (const entry of registry.createReadStream({
      gt: 'federation:',
      lt: 'federation:\xff'
    })) {
      peers.push(entry.value)
    }

    const selfKey = serverIdentity
      ? serverIdentity.getPublicKeyHex()
      : null

    json(res, { peers, selfKey, count: peers.length })
  } catch (err) {
    console.error('[Federation] Peers error:', err)
    json(res, { peers: [], selfKey: null, count: 0 })
  }
}

/**
 * GET /api/federation/peers/:key/catalog
 * Fetch a remote server's groups and repos via P2P browse.
 */
async function handlePeerCatalog (req, res, server) {
  const { registry } = server.coreData
  const { namespaceRegistry } = server.namespaceData
  const { browseClient } = server.p2p
  const { key } = req.params

  // Verify peer is known — check federation Hyperbee OR namespace registry writers
  let peerKnown = false
  try {
    const entry = await registry.get(`federation:${key}`)
    if (entry && entry.value.status === 'active') {
      peerKnown = true
    }
  } catch {}

  if (!peerKnown && namespaceRegistry) {
    try {
      const writers = await namespaceRegistry.listWriters()
      peerKnown = writers.some(w => w.serverKey === key && !w.removedAt)
    } catch {}
  }

  if (!peerKnown) {
    return notFound(res, 'Peer not found in federation or registry')
  }

  if (!browseClient) {
    return json(res, { error: 'P2P browse not available', offline: true })
  }

  try {
    const catalog = await browseClient.browseServer(key, { command: 'serverCatalog' }, { timeout: 15000 })

    if (catalog.error) {
      return json(res, { error: catalog.error, offline: true })
    }

    // Unwrap browse response envelope
    const data = catalog.data || catalog
    json(res, data)
  } catch (err) {
    console.error(`[Federation] Catalog fetch failed for ${key.slice(0, 8)}...: ${err.message}`)
    json(res, { error: err.message, offline: true })
  }
}

/**
 * DELETE /api/federation/peers/:key
 * Revoke a federated server.
 */
async function handleFederationRevoke (req, res, server) {
  const { registry } = server.coreData
  const { key } = req.params

  try {
    const entry = await registry.get(`federation:${key}`)
    if (!entry) {
      return notFound(res, 'Federated server not found')
    }

    // Mark as revoked
    const record = entry.value
    record.status = 'revoked'
    record.revokedAt = Date.now()
    await registry.put(`federation:${key}`, record)

    json(res, { success: true, serverKey: key })
  } catch (err) {
    console.error('[Federation] Revoke error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * PATCH /api/federation/peers/:key
 * Update a federated server's metadata (e.g., httpUrl).
 */
async function handleFederationPeerUpdate (req, res, server) {
  const { registry } = server.coreData
  const { key } = req.params
  const body = await readJson(req)

  try {
    const entry = await registry.get(`federation:${key}`)
    if (!entry) {
      return notFound(res, 'Federated server not found')
    }

    const record = entry.value
    if (body.httpUrl !== undefined) record.httpUrl = body.httpUrl
    if (body.displayName !== undefined) record.displayName = body.displayName
    record.updatedAt = Date.now()

    await registry.put(`federation:${key}`, record)
    json(res, { success: true, ...record })
  } catch (err) {
    console.error('[Federation] Peer update error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * POST /api/repos/:name/sync
 * Clone a repo from a remote server.
 * Body: { url?: string } — optional URL override, otherwise auto-detected from federation peers
 */
async function handleRepoSync (req, res, server) {
  const { repoRegistry } = server.repoData
  const { serverIdentity } = server.p2p
  const { namespaceRegistry } = server.namespaceData
  const { registry } = server.coreData
  const { name: repoName } = req.params
  const body = await readJson(req)

  // Check if already exists locally
  const existing = await repoRegistry.get(repoName)
  if (existing) {
    return badRequest(res, `Repo "${repoName}" already exists locally`)
  }

  // Determine clone URL
  let cloneUrl = body.url || null

  if (!cloneUrl) {
    // Try P2P first — use punch:// URLs via serverKey from federation or namespace writers
    const selfKey = serverIdentity ? serverIdentity.getPublicKeyHex() : null

    // Check namespace registry writers for serverKey
    if (namespaceRegistry) {
      try {
        const writers = await namespaceRegistry.listWriters()
        for (const w of writers) {
          if (w.removedAt) continue
          if (w.serverKey && w.serverKey !== selfKey) {
            cloneUrl = `punch://${w.serverKey}/${repoName}`
            break
          }
        }
      } catch (err) {
        console.error('[Sync] Error reading writers:', err)
      }
    }
  }

  if (!cloneUrl) {
    // Fall back to federation entries — prefer punch://, fall back to HTTP
    try {
      for await (const entry of registry.createReadStream({
        gt: 'federation:',
        lt: 'federation:\xff'
      })) {
        const peer = entry.value
        if (peer.status === 'active') {
          if (peer.serverKey) {
            cloneUrl = `punch://${peer.serverKey}/${repoName}`
          } else if (peer.httpUrl) {
            cloneUrl = `${peer.httpUrl}/${repoName}.git`
          }
          if (cloneUrl) break
        }
      }
    } catch (err) {
      console.error('[Sync] Error reading federation store:', err)
    }
  }

  if (!cloneUrl) {
    return badRequest(res, 'No peers available for sync. Link servers with: pgh peer add --mirror')
  }

  const reposDir = repoRegistry.gitReposPath || path.join(process.env.HOME || '/tmp', 'git-repos')
  const targetPath = path.join(reposDir, `${repoName}.git`)

  console.log(`[Sync] Cloning ${repoName} from ${cloneUrl} → ${targetPath}`)

  try {
    await new Promise((resolve, reject) => {
      const git = spawn('git', ['clone', '--bare', cloneUrl, targetPath])
      let stderr = ''
      git.stderr.on('data', (d) => { stderr += d.toString() })
      git.on('exit', (code) => {
        if (code !== 0) reject(new Error(`git clone failed (code ${code}): ${stderr.trim()}`))
        else resolve()
      })
      git.on('error', reject)
    })

    // Re-scan to pick up the new repo
    const scannedRepos = await repoRegistry.scan({
      paths: [reposDir],
      maxDepth: 1
    })
    if (scannedRepos.length > 0) {
      await repoRegistry.registerAll(scannedRepos)
    }
    server.repos = await repoRegistry.listAll()

    console.log(`[Sync] ✓ Cloned ${repoName}`)
    json(res, { success: true, repoName, cloneUrl })
  } catch (err) {
    console.error(`[Sync] Failed to clone ${repoName}:`, err.message)
    json(res, { error: err.message }, 500)
  }
}

/**
 * PATCH /api/sync/:repoName
 * Set sync preference for a repo.
 * Body: { sync: boolean }
 */
async function handleSyncUpdate (req, res, server) {
  const { registry } = server.coreData
  const { repoName } = req.params
  const body = await readJson(req)

  if (typeof body.sync !== 'boolean') {
    return badRequest(res, 'sync (boolean) is required')
  }

  await registry.put(`sync-config:${repoName}`, {
    sync: body.sync,
    updatedAt: Date.now()
  })

  json(res, { success: true, repoName, sync: body.sync })
}

/**
 * GET /api/sync/:repoName
 * Get sync preference for a repo.
 */
async function handleSyncGet (req, res, server) {
  const { registry } = server.coreData
  const { repoName } = req.params

  try {
    const entry = await registry.get(`sync-config:${repoName}`)
    if (!entry) {
      return json(res, { repoName, sync: true, default: true })
    }
    json(res, { repoName, ...entry.value })
  } catch {
    json(res, { repoName, sync: true, default: true })
  }
}

/**
 * GET /api/sync
 * List all sync preferences.
 */
async function handleSyncList (req, res, server) {
  const { registry } = server.coreData
  try {
    const configs = []
    for await (const entry of registry.createReadStream({
      gt: 'sync-config:',
      lt: 'sync-config:\xff'
    })) {
      const repoName = entry.key.slice('sync-config:'.length)
      configs.push({ repoName, ...entry.value })
    }
    json(res, { configs, count: configs.length })
  } catch (err) {
    console.error('[Sync] List error:', err)
    json(res, { configs: [], count: 0 })
  }
}