OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / routes / invites-api.js

467 lines · javascript ref:
/**
 * Invite & Fork API Routes
 *
 * Two models for P2P repo access:
 *
 * Model A (Server Gatekeeper):
 *   Invite codes grant push access to server-owned repos.
 *   Server is the single Hyperdrive writer, peers submit changes through it.
 *
 * Model B (Fork & Pull):
 *   Peers create their own Hyperdrive fork. Each peer owns their fork's keypair
 *   and can write freely. The origin server replicates from forks to pull changes.
 *   Git handles merges naturally — no Autobase needed.
 *
 * Invite flow (both models):
 *   1. Server creates invite → includes origin drive key
 *   2. Peer accepts → optionally provides their fork drive key
 *   3. Server tracks forks → joins their Hyperswarm topics to replicate
 *   4. Git merges happen at whichever end wants to integrate changes
 *
 * Endpoints:
 * - POST   /api/p2p/invites           - Create invite code
 * - POST   /api/p2p/invites/accept    - Accept an invite code
 * - GET    /api/p2p/invites           - List invites (?repo= filter)
 * - DELETE /api/p2p/invites/:code     - Revoke an invite
 * - GET    /api/p2p/forks             - List forks (?repo= filter)
 * - POST   /api/p2p/forks             - Register a fork
 * - DELETE /api/p2p/forks/:key        - Unregister a fork
 */

import crypto from 'bare-crypto'
import Hyperdrive from 'hyperdrive'
import { json, badRequest, notFound, forbidden, readJson } from '../response.js'
import { requireAuth } from '../middleware/require-auth.js'

/**
 * Parse expiry string to milliseconds from now
 * Supports: 1h, 24h, 7d, 30d, never
 */
function parseExpiry (str) {
  if (!str || str === 'never') return null

  const match = str.match(/^(\d+)([hd])$/)
  if (!match) return null

  const num = parseInt(match[1])
  const unit = match[2]

  if (unit === 'h') return Date.now() + num * 60 * 60 * 1000
  if (unit === 'd') return Date.now() + num * 24 * 60 * 60 * 1000
  return null
}

/**
 * Generate a random invite code (24 hex chars)
 */
function generateCode () {
  return crypto.randomBytes(12).toString('hex')
}

/**
 * Register invite and fork API routes
 */
export function registerInviteRoutes (router) {
  // Model A: Server gatekeeper invites
  router.post('/api/p2p/invites', requireAuth, handleCreateInvite)
  router.post('/api/p2p/invites/accept', requireAuth, handleAcceptInvite)
  router.get('/api/p2p/invites', requireAuth, handleListInvites)
  router.delete('/api/p2p/invites/:code', requireAuth, handleRevokeInvite)

  // Model B: Fork tracking
  router.get('/api/p2p/forks', handleListForks)
  router.post('/api/p2p/forks', requireAuth, handleRegisterFork)
  router.delete('/api/p2p/forks/:key', requireAuth, handleUnregisterFork)
}

// ---------------------------------------------------------------------------
// Model A: Invite CRUD
// ---------------------------------------------------------------------------

/**
 * Create an invite code for a repository.
 * Includes the origin drive key so the accepting peer can clone/fork.
 */
async function handleCreateInvite (req, res, server) {
  const { repos, backendManager } = server.repoData
  const { authManager } = server.auth
  const { registry } = server.coreData
  try {
    const { repoName, permissions, expires } = await readJson(req)

    if (!repoName) {
      badRequest(res, 'repoName is required')
      return
    }

    // Verify repo exists
    const repo = repos.find(r => r.name === repoName)
    if (!repo) {
      notFound(res, `Repository not found: ${repoName}`)
      return
    }

    // Require write permission on the target repo (not just any auth)
    const hasAccess = authManager.hasRepoAccess(req.auth, repo, 'write')
    if (!hasAccess) {
      forbidden(res, `Write access required on ${repoName} to create invites`)
      return
    }

    const code = generateCode()
    const expiresAt = parseExpiry(expires || '24h')
    const perms = Array.isArray(permissions) ? permissions : ['read']

    // Detect repo strategy and get drive key if Hyperdrive
    let strategy = 'direct-git'
    let originDriveKey = null
    try {
      strategy = await backendManager.getRepoStrategy(repoName)
      if (strategy === 'hyperdrive') {
        originDriveKey = await backendManager.getDriveKey(repoName)
      }
    } catch {}

    const invite = {
      code,
      repoName,
      strategy,
      permissions: perms,
      originDriveKey,
      createdBy: req.auth?.userId || 'anonymous',
      createdAt: Date.now(),
      expiresAt,
      usedBy: []
    }

    await registry.put(`invite:${code}`, invite)

    console.log(`[Invites] Created invite ${code.slice(0, 12)}... for ${repoName} (${strategy})`)
    json(res, {
      code,
      repoName,
      strategy,
      permissions: perms,
      expiresAt,
      originDriveKey
    })
  } catch (err) {
    console.error('[Invites] Create error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Accept an invite code.
 * Optionally include a forkDriveKey to register a Model B fork.
 */
async function handleAcceptInvite (req, res, server) {
  const { registry } = server.coreData
  try {
    const { code, forkDriveKey, peerKey } = await readJson(req)

    if (!code) {
      badRequest(res, 'code is required')
      return
    }

    const entry = await registry.get(`invite:${code}`)
    if (!entry || !entry.value) {
      json(res, { success: false, error: 'invalid or expired' })
      return
    }

    const invite = entry.value

    // Check expiry
    if (invite.expiresAt && Date.now() > invite.expiresAt) {
      await registry.del(`invite:${code}`)
      json(res, { success: false, error: 'invite expired' })
      return
    }

    const userId = req.auth?.userId || 'anonymous'

    // Record usage
    invite.usedBy.push({
      userId,
      peerKey: peerKey || null,
      forkDriveKey: forkDriveKey || null,
      acceptedAt: Date.now()
    })
    await registry.put(`invite:${code}`, invite)

    // Grant peer access keyed by userId (for HTTP API auth)
    const accessKey = `peer-access:${invite.repoName}:${userId}`
    await registry.put(accessKey, {
      repoName: invite.repoName,
      userId,
      peerKey: peerKey || null,
      permissions: invite.permissions,
      grantedBy: invite.createdBy,
      grantedAt: Date.now(),
      inviteCode: code
    })

    // Also grant access keyed by Hyperswarm public key (for P2P push auth)
    if (peerKey && /^[0-9a-f]{64}$/i.test(peerKey)) {
      const p2pAccessKey = `peer-access:${invite.repoName}:${peerKey}`
      await registry.put(p2pAccessKey, {
        repoName: invite.repoName,
        userId,
        peerKey,
        permissions: invite.permissions,
        grantedBy: invite.createdBy,
        grantedAt: Date.now(),
        inviteCode: code
      })
    }

    // Register fork if provided (Model B)
    if (forkDriveKey) {
      await storeFork(server, {
        repoName: invite.repoName,
        driveKey: forkDriveKey,
        ownerId: userId,
        inviteCode: code
      })
    }

    const strategy = invite.strategy || 'direct-git'
    console.log(`[Invites] ${userId} accepted invite ${code.slice(0, 12)}... for ${invite.repoName} (${strategy})`)
    json(res, {
      success: true,
      repoName: invite.repoName,
      strategy,
      permissions: invite.permissions,
      originDriveKey: invite.originDriveKey,
      forkRegistered: !!forkDriveKey,
      userId
    })
  } catch (err) {
    console.error('[Invites] Accept error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * List invites (optional ?repo= filter)
 */
async function handleListInvites (req, res, server) {
  const { registry } = server.coreData
  try {
    const url = new URL(req.url, 'http://localhost')
    const repoFilter = url.searchParams.get('repo')

    const invites = []
    for await (const entry of registry.createReadStream({
      gte: 'invite:',
      lt: 'invite:\xff'
    })) {
      const invite = entry.value
      if (repoFilter && invite.repoName !== repoFilter) continue
      if (invite.expiresAt && Date.now() > invite.expiresAt) continue
      invites.push(invite)
    }

    json(res, { invites, count: invites.length })
  } catch (err) {
    console.error('[Invites] List error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Revoke an invite code
 */
async function handleRevokeInvite (req, res, server) {
  const { registry } = server.coreData
  try {
    const { code } = req.params

    const entry = await registry.get(`invite:${code}`)
    if (!entry || !entry.value) {
      json(res, { success: false, error: 'not found' })
      return
    }

    // Only the invite creator or an admin can revoke
    const invite = entry.value
    const isAdmin = req.auth?.permissions?.admin ||
                    req.auth?.user?.globalPermissions?.includes('admin')
    const isCreator = invite.createdBy && invite.createdBy === req.auth?.userId
    if (!isAdmin && !isCreator) {
      forbidden(res, 'Only the invite creator or an admin can revoke invites')
      return
    }

    await registry.del(`invite:${code}`)

    console.log(`[Invites] Revoked invite ${code.slice(0, 12)}...`)
    json(res, { success: true })
  } catch (err) {
    console.error('[Invites] Revoke error:', err)
    json(res, { error: err.message }, 500)
  }
}

// ---------------------------------------------------------------------------
// Model B: Fork tracking & replication
// ---------------------------------------------------------------------------

/**
 * Store a fork record and start replicating from it.
 * The server joins the fork's Hyperswarm topic as a CLIENT to pull data.
 */
async function storeFork (server, { repoName, driveKey, ownerId, inviteCode }) {
  const { registry } = server.coreData
  const fork = {
    repoName,
    driveKey,
    ownerId,
    inviteCode: inviteCode || null,
    registeredAt: Date.now(),
    lastSeenAt: null
  }

  await registry.put(`fork:${repoName}:${driveKey}`, fork)

  // Start replicating from this fork
  await replicateFromFork(server, fork)

  console.log(`[Forks] Registered fork of ${repoName} by ${ownerId}: ${driveKey.slice(0, 16)}...`)
  return fork
}

/**
 * Join a fork's Hyperswarm topic as CLIENT to pull their Hyperdrive.
 * The corestore.replicate(conn) on each connection automatically
 * replicates any cores the peer has — including their Hyperdrive.
 */
async function replicateFromFork (server, fork) {
  const { backendManager } = server.repoData
  const backend = backendManager.getBackendByStrategy('hyperdrive')
  if (!backend) return

  await backend.initSwarm()

  // Create a Hyperdrive pointing at the fork's key (read-only)
  const forkKey = Buffer.from(fork.driveKey, 'hex')
  const forkDrive = new Hyperdrive(backend.corestore, forkKey)
  await forkDrive.ready()

  // Join swarm as CLIENT (we want to pull from them, not serve)
  const discovery = backend.swarm.join(forkDrive.discoveryKey, { server: false, client: true })
  await discovery.flushed()

  console.log(`[Forks] Replicating from fork: ${fork.driveKey.slice(0, 16)}... (${fork.repoName})`)
}

/**
 * List forks (optional ?repo= filter)
 */
async function handleListForks (req, res, server) {
  const { registry } = server.coreData
  try {
    const url = new URL(req.url, 'http://localhost')
    const repoFilter = url.searchParams.get('repo')

    const forks = []
    for await (const entry of registry.createReadStream({
      gte: 'fork:',
      lt: 'fork:\xff'
    })) {
      const fork = entry.value
      if (repoFilter && fork.repoName !== repoFilter) continue
      forks.push(fork)
    }

    json(res, { forks, count: forks.length })
  } catch (err) {
    console.error('[Forks] List error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Register a fork (peer shares their Hyperdrive key with the server).
 * Server starts replicating from the fork.
 */
async function handleRegisterFork (req, res, server) {
  const { repos, backendManager } = server.repoData
  try {
    const { repoName, driveKey } = await readJson(req)

    if (!repoName || !driveKey) {
      badRequest(res, 'repoName and driveKey are required')
      return
    }

    // Verify repo exists
    const repo = repos.find(r => r.name === repoName)
    if (!repo) {
      notFound(res, `Repository not found: ${repoName}`)
      return
    }

    // Validate key format (64 hex chars)
    if (!/^[0-9a-f]{64}$/i.test(driveKey)) {
      badRequest(res, 'driveKey must be a 64-char hex string')
      return
    }

    const userId = req.auth?.userId || 'anonymous'

    // Get origin drive key for response
    let originDriveKey = null
    try {
      originDriveKey = await backendManager.getDriveKey(repoName)
    } catch {}

    const fork = await storeFork(server, { repoName, driveKey, ownerId: userId })

    json(res, {
      success: true,
      fork,
      originDriveKey
    })
  } catch (err) {
    console.error('[Forks] Register error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Unregister a fork (stop replicating from it)
 */
async function handleUnregisterFork (req, res, server) {
  const { registry } = server.coreData
  try {
    const { key } = req.params

    // Find the fork by drive key
    let found = false
    for await (const entry of registry.createReadStream({
      gte: 'fork:',
      lt: 'fork:\xff'
    })) {
      if (entry.value.driveKey === key) {
        await registry.del(entry.key)
        found = true
        console.log(`[Forks] Unregistered fork: ${key.slice(0, 16)}...`)
        break
      }
    }

    if (!found) {
      json(res, { success: false, error: 'fork not found' })
      return
    }

    json(res, { success: true })
  } catch (err) {
    console.error('[Forks] Unregister error:', err)
    json(res, { error: err.message }, 500)
  }
}