/**
* 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)
}