OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / routes / linking.js

356 lines · javascript ref:
/**
 * Repo Linking API Routes
 *
 * Manages Autobase-backed collaborative repos:
 * - Link/unlink repos (create Autobase overlay)
 * - Manage writers/peers
 * - Create invites for blind-pairing key exchange
 * - CRUD issues on linked repos
 *
 * Endpoints:
 * - POST   /api/repos/:name/link         Create LinkedRepo + return invite
 * - POST   /api/repos/:name/invite       Create new invite for existing linked repo
 * - GET    /api/repos/:name/peers        List writers/mirrors
 * - DELETE /api/repos/:name/unlink       Remove Autobase overlay
 *
 * - GET    /api/repos/:name/linked-issues         List issues
 * - POST   /api/repos/:name/linked-issues         Create issue
 * - GET    /api/repos/:name/linked-issues/:id     Get issue + comments
 * - POST   /api/repos/:name/linked-issues/:id/comments  Add comment
 * - PATCH  /api/repos/:name/linked-issues/:id     Update issue
 */

import { json, badRequest, notFound, readJson } from '../response.js'
import { requireAuth } from '../middleware/require-auth.js'
import { RepoFacade } from '../facades/repo-facade.js'
import { RepoLinker } from '../repo-link/repo-linker.js'

/**
 * Register linking routes
 */
export function registerLinkingRoutes (router) {
  // Linking management
  router.post('/api/repos/:name/link', requireAuth, handleLinkRepo)
  router.post('/api/repos/:name/join', requireAuth, handleJoinRepo)
  router.post('/api/repos/:name/invite', requireAuth, handleCreateInvite)
  router.get('/api/repos/:name/peers', handleListPeers)
  router.delete('/api/repos/:name/unlink', requireAuth, handleUnlinkRepo)

  // Linked issue management
  router.get('/api/repos/:name/linked-issues', handleListIssues)
  router.post('/api/repos/:name/linked-issues', requireAuth, handleCreateIssue)
  router.get('/api/repos/:name/linked-issues/:id', handleGetIssue)
  router.post('/api/repos/:name/linked-issues/:id/comments', requireAuth, handleCreateComment)
  router.patch('/api/repos/:name/linked-issues/:id', requireAuth, handleUpdateIssue)
}

// ---------------------------------------------------------------------------
// Linking Management
// ---------------------------------------------------------------------------

/**
 * POST /api/repos/:name/link
 * Create a new LinkedRepo (Autobase overlay) for an existing repo
 */
async function handleLinkRepo (req, res, server) {
  const { repoRegistry, linkedRepos } = server.repoData
  const { store } = server.coreData
  const { serverIdentity } = server.p2p
  const { name } = req.params

  const repo = await repoRegistry.get(name)
  if (!repo) return notFound(res, `Repository not found: ${name}`)

  // Check if already linked
  if (repo.linked) {
    return badRequest(res, `Repository ${name} is already linked`)
  }

  // Reuse existing LinkedRepo (every repo already has one for issues)
  // or create a new one if somehow missing
  let linked = linkedRepos.get(name)
  if (!linked) {
    linked = new RepoFacade({
      metadataBase: server.metadataBase,
      repoName: name
    })
    server.linkedRepos.set(name, linked)
  }

  const bootstrapKey = linked.bootstrapKeyHex

  // Add creator as owner writer
  const userId = req.auth ? req.auth.userId : 'anonymous'
  const serverKey = serverIdentity
    ? serverIdentity.getPublicKeyHex()
    : '0'.repeat(64)

  // DANGER: serverKey bug — passes Ed25519 identity key, not Hypercore key.
  // See docs/subsystems/LINKED-REPO.md for details.
  await linked.addWriter(userId, serverKey, null, null, 'owner')

  // Store in registry
  await repoRegistry.setLinked(name, {
    bootstrapKey,
    role: 'owner'
  })

  // Create invite for sharing
  // MUST use a fresh swarm — blind-pairing sends protomux frames that
  // conflict with the git protocol on server.swarm (TopicManager routes
  // ALL connections to GitP2PHandler which expects raw service\0repo\0).
  const linker = new RepoLinker({ bootstrap: server._bootstrap })
  let inviteHex = null

  try {
    const result = await linker.createInvite({
      bootstrapKey,
      repoName: name,
      role: 'writer'
    })
    inviteHex = result.inviteHex

    // Don't await waitForPeer — return immediately with invite
  } catch (err) {
    console.error(`[Linking] Failed to create invite:`, err.message)
  }

  json(res, {
    success: true,
    name,
    bootstrapKey,
    invite: inviteHex,
    role: 'owner'
  })
}

/**
 * POST /api/repos/:name/join
 * Join a linked repo using an invite hex from another server
 */
async function handleJoinRepo (req, res, server) {
  const { repoRegistry } = server.repoData
  const { store } = server.coreData
  const { serverIdentity } = server.p2p
  const { name } = req.params
  const body = await readJson(req).catch(() => ({}))
  const { invite } = body

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

  const repo = await repoRegistry.get(name)
  if (!repo) return notFound(res, `Repository not found: ${name}`)

  const userId = req.auth ? req.auth.userId : 'anonymous'
  const serverKey = serverIdentity
    ? serverIdentity.getPublicKeyHex()
    : '0'.repeat(64)

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

  try {
    const result = await linker.joinWithInvite(invite, {
      userId,
      serverKey,
      repoName: name
    })

    // Create LinkedRepo with the bootstrap key from the owner
    const linked = new RepoFacade({
      metadataBase: server.metadataBase,
      repoName: name
    })

    // Store in registry
    await repoRegistry.setLinked(name, {
      bootstrapKey: result.bootstrapKey,
      role: result.role || 'writer'
    })

    // Cache in server
    server.linkedRepos.set(name, linked)

    json(res, {
      success: true,
      name,
      bootstrapKey: result.bootstrapKey,
      role: result.role || 'writer'
    })
  } catch (err) {
    badRequest(res, `Failed to join: ${err.message}`)
  } finally {
    await linker.close()
  }
}

/**
 * POST /api/repos/:name/invite
 * Create a new invite for an existing linked repo
 */
async function handleCreateInvite (req, res, server) {
  const { linkedRepos } = server.repoData
  const { name } = req.params
  const body = await readJson(req).catch(() => ({}))
  const role = body.role || 'writer'

  const linked = linkedRepos.get(name)
  if (!linked) return notFound(res, `Repository ${name} is not linked`)

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

  try {
    const result = await linker.createInvite({
      bootstrapKey: linked.bootstrapKeyHex,
      repoName: name,
      role
    })

    json(res, {
      invite: result.inviteHex,
      repoName: name,
      role
    })
  } catch (err) {
    badRequest(res, `Failed to create invite: ${err.message}`)
  }
}

/**
 * GET /api/repos/:name/peers
 * List writers/mirrors for a linked repo
 */
async function handleListPeers (req, res, server) {
  const { linkedRepos } = server.repoData
  const { name } = req.params

  const linked = linkedRepos.get(name)
  if (!linked) return notFound(res, `Repository ${name} is not linked`)

  const writers = await linked.listWriters()
  json(res, { writers, count: writers.length })
}

/**
 * DELETE /api/repos/:name/unlink
 * Remove the Autobase overlay from a repo
 */
async function handleUnlinkRepo (req, res, server) {
  const { linkedRepos, repoRegistry } = server.repoData
  const { name } = req.params

  const linked = linkedRepos.get(name)
  if (!linked) return notFound(res, `Repository ${name} is not linked`)

  await linked.close()
  server.linkedRepos.delete(name)
  await repoRegistry.unlink(name)

  json(res, { success: true, name })
}

// ---------------------------------------------------------------------------
// Issue Management (Linked)
// ---------------------------------------------------------------------------

/**
 * GET /api/repos/:name/linked-issues
 * List issues from linked repo Autobase
 */
async function handleListIssues (req, res, server) {
  const { linkedRepos } = server.repoData
  const { name } = req.params

  const linked = linkedRepos.get(name)
  if (!linked) return notFound(res, `Repository ${name} is not linked`)

  const issues = await linked.listIssues()
  json(res, { issues, count: issues.length })
}

/**
 * POST /api/repos/:name/linked-issues
 * Create an issue on a linked repo
 */
async function handleCreateIssue (req, res, server) {
  const { linkedRepos } = server.repoData
  const { name } = req.params
  const { title, body } = await readJson(req)

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

  const linked = linkedRepos.get(name)
  if (!linked) return notFound(res, `Repository ${name} is not linked`)

  const userId = req.auth ? req.auth.userId : 'anonymous'
  const id = await linked.createIssue(title, body || '', userId)
  const issue = await linked.getIssue(id)

  json(res, issue, 201)
}

/**
 * GET /api/repos/:name/linked-issues/:id
 * Get a single issue with its comments
 */
async function handleGetIssue (req, res, server) {
  const { linkedRepos } = server.repoData
  const { name, id } = req.params

  const linked = linkedRepos.get(name)
  if (!linked) return notFound(res, `Repository ${name} is not linked`)

  const issueId = parseInt(id, 10)
  if (isNaN(issueId)) return badRequest(res, 'Invalid issue ID')
  const issue = await linked.getIssue(issueId)
  if (!issue) return notFound(res, `Issue #${id} not found`)

  const comments = await linked.getComments(issueId)

  json(res, { ...issue, comments })
}

/**
 * POST /api/repos/:name/linked-issues/:id/comments
 * Add a comment to an issue
 */
async function handleCreateComment (req, res, server) {
  const { linkedRepos } = server.repoData
  const { name, id } = req.params
  const { body } = await readJson(req)

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

  const linked = linkedRepos.get(name)
  if (!linked) return notFound(res, `Repository ${name} is not linked`)

  const userId = req.auth ? req.auth.userId : 'anonymous'
  const parsedId = parseInt(id, 10)
  if (isNaN(parsedId)) return badRequest(res, 'Invalid issue ID')
  const commentId = await linked.createComment(parsedId, body, userId)
  const comments = await linked.getComments(parsedId)
  const comment = comments.find(c => c.commentId === commentId)

  json(res, comment || { commentId, body, author: userId }, 201)
}

/**
 * PATCH /api/repos/:name/linked-issues/:id
 * Update an issue (close, reopen, edit)
 */
async function handleUpdateIssue (req, res, server) {
  const { linkedRepos } = server.repoData
  const { name, id } = req.params
  const updates = await readJson(req)

  const linked = linkedRepos.get(name)
  if (!linked) return notFound(res, `Repository ${name} is not linked`)

  const userId = req.auth ? req.auth.userId : 'anonymous'
  const parsedId = parseInt(id, 10)
  if (isNaN(parsedId)) return badRequest(res, 'Invalid issue ID')
  await linked.updateIssue(parsedId, updates, userId)
  const issue = await linked.getIssue(parsedId)

  json(res, issue)
}