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