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