/**
* Namespace Routes
*
* Handles:
* - GET /api/namespaces — List all namespaces
* - GET /api/namespaces/:name — Get single namespace
* - POST /api/namespaces — Create namespace (admin)
* - PATCH /api/namespaces/:name — Update namespace (admin)
* - DELETE /api/namespaces/:name — Delete if empty (admin)
* - POST /api/namespaces/:name/repos — Add repo
* - DELETE /api/namespaces/:name/repos/:repo — Remove repo
* - POST /api/namespaces/:name/members — Add member
* - DELETE /api/namespaces/:name/members/:id — Remove member
*
* - GET /groups — Groups list page (HTML)
* - GET /groups/:name — Group detail page (HTML)
*/
import { html, json, badRequest, notFound, readJson } from '../response.js'
import { requireAdmin } from '../middleware/admin.js'
import { requireAuth } from '../middleware/require-auth.js'
import { renderGroupsPage, renderGroupPage } from '../namespace-ui.js'
import { RepoLinker } from '../repo-link/repo-linker.js'
import { GroupFacade } from '../facades/group-facade.js'
// ========================================
// Smart routing helpers — merge local + shared
// ========================================
/**
* List: merge local + shared, deduplicate by name.
* - 'local' group is per-server — never overridden by shared registry
* - Other groups: shared metadata wins, but repos are merged from both sources
*/
async function listNamespaces (server, filters) {
const { namespaceManager, namespaceRegistry } = server.namespaceData
const local = await namespaceManager.list(filters)
if (!namespaceRegistry) return local.map(g => ({ ...g, source: 'local' }))
const shared = await namespaceRegistry.list(filters)
const byName = new Map()
for (const g of local) byName.set(g.name, { ...g, source: 'local' })
for (const g of shared) {
// 'local' group is per-server — never let shared registry override it
if (g.name === 'local') continue
const existing = byName.get(g.name)
if (existing) {
// Merge: shared metadata wins, but combine repos from both sources
const mergedRepos = [...new Set([...g.repos, ...existing.repos])]
byName.set(g.name, { ...g, repos: mergedRepos, source: 'shared' })
} else {
byName.set(g.name, { ...g, source: 'shared' })
}
}
return [...byName.values()]
}
/**
* Get: check shared first (authoritative for shared groups), fall back to local
*/
async function getNamespace (server, name) {
const { namespaceManager, namespaceRegistry } = server.namespaceData
if (namespaceRegistry) {
const shared = await namespaceRegistry.get(name)
if (shared) return { ...shared, source: 'shared' }
}
const local = await namespaceManager.get(name)
return local ? { ...local, source: 'local' } : null
}
/**
* Write: always prefer shared MetadataBase (replicates to peers).
* Fall back to local manager only if shared registry unavailable.
*/
async function getBackendFor (server, name) {
const { namespaceManager, namespaceRegistry } = server.namespaceData
if (namespaceRegistry) return namespaceRegistry
return namespaceManager
}
/**
* Announce registry discovery key on driveSwarm for replication
*/
function announceRegistry (server) {
const { namespaceRegistry } = server.namespaceData
const { driveSwarm } = server.p2p
if (namespaceRegistry && namespaceRegistry.discoveryKey && driveSwarm) {
driveSwarm.join(namespaceRegistry.discoveryKey)
}
}
/**
* Copy a single local group into the shared registry
*/
async function copyGroupToRegistry (server, group) {
const { namespaceRegistry } = server.namespaceData
const reg = namespaceRegistry
const existing = await reg.get(group.name)
if (existing) return
await reg.create(group.name, group.type || 'group', {
displayName: group.displayName,
description: group.description
})
for (const repoName of (group.repos || [])) {
try { await reg.addRepo(group.name, repoName) } catch { /* ignore */ }
}
for (const member of (group.members || [])) {
try { await reg.addMember(group.name, member.userId, member.role) } catch { /* ignore */ }
}
}
/**
* Register namespace routes
*/
export function registerNamespaceRoutes (router) {
// HTML pages (before API routes — "groups" and "join" are reserved names)
router.get('/groups', handleGroupsPage)
router.get('/groups/:name', handleGroupPage)
router.get('/join', handleJoinPage)
// API: list and get (public)
router.get('/api/namespaces', handleListNamespaces)
router.get('/api/namespaces/:name', handleGetNamespace)
// API: create, update, delete (admin)
router.post('/api/namespaces', requireAdmin, handleCreateNamespace)
router.patch('/api/namespaces/:name', requireAdmin, handleUpdateNamespace)
router.delete('/api/namespaces/:name', requireAdmin, handleDeleteNamespace)
// API: repo membership (admin)
router.post('/api/namespaces/:name/repos', requireAdmin, handleAddRepo)
router.delete('/api/namespaces/:name/repos/:repo', requireAdmin, handleRemoveRepo)
// API: group members (admin)
router.post('/api/namespaces/:name/members', requireAdmin, handleAddMember)
router.delete('/api/namespaces/:name/members/:id', requireAdmin, handleRemoveMember)
// API: reconsolidate groups (admin)
router.post('/api/namespaces/reconsolidate', requireAdmin, handleReconsolidate)
// API: server linking — legacy routes redirect to federation/access layer
router.post('/api/namespaces/:name/invite', requireAdmin, handleGroupInviteLegacy)
router.post('/api/groups/join', requireAuth, handleGroupJoinLegacy)
router.get('/api/groups/:name/sync-status', handleGroupSyncStatus)
router.patch('/api/groups/:name/sync', requireAuth, handleGroupSyncUpdate)
}
// ========================================
// HTML Pages
// ========================================
async function handleGroupsPage (req, res, server) {
const { namespaceRegistry } = server.namespaceData
const { serverIdentity } = server.p2p
const { registry } = server.coreData
const { pathPrefix } = server.config
try {
const groups = await listNamespaces(server, { type: 'group' })
// Build registry status for status banner
let registryStatus = { active: false, mode: 'local', writerCount: 0 }
const selfKey = serverIdentity ? serverIdentity.getPublicKeyHex() : null
const remotePeers = []
if (namespaceRegistry) {
const writers = await namespaceRegistry.listWriters().catch(() => [])
registryStatus = { active: true, mode: 'shared', writerCount: writers.length }
// Collect remote peer keys for catalog fetching
const seen = new Set()
for (const w of writers) {
if (w.removedAt) continue
if (w.serverKey && w.serverKey !== selfKey && !seen.has(w.serverKey)) {
seen.add(w.serverKey)
remotePeers.push({ serverKey: w.serverKey, displayName: w.peerId || w.serverKey.slice(0, 12) })
}
}
}
// Also check federation Hyperbee for peers with displayName/httpUrl metadata
try {
for await (const entry of registry.createReadStream({ gt: 'federation:', lt: 'federation:\xff' })) {
const peer = entry.value
if (peer.status !== 'active' || peer.serverKey === selfKey) continue
const existing = remotePeers.find(p => p.serverKey === peer.serverKey)
if (existing) {
// Merge displayName from federation record (better names)
if (peer.displayName) existing.displayName = peer.displayName
} else {
remotePeers.push({ serverKey: peer.serverKey, displayName: peer.displayName || peer.serverKey.slice(0, 12) })
}
}
} catch {}
const content = renderGroupsPage(groups, pathPrefix || '', registryStatus, remotePeers)
html(res, content)
} catch (err) {
console.error('[Groups] Error rendering groups page:', err)
html(res, '<h1>Error loading groups</h1>', 500)
}
}
async function handleGroupPage (req, res, server) {
const { namespaceRegistry } = server.namespaceData
const { repoRegistry, repos: allRepos } = server.repoData
const { serverIdentity } = server.p2p
const { pathPrefix } = server.config
try {
const { name } = req.params
const group = await getNamespace(server, name)
if (!group) {
notFound(res, `Group "${name}" not found`)
return
}
// Load repo details for repos in this group
// Include repos even if not locally available (remote/unsynced)
const repos = []
for (const repoName of group.repos) {
const repo = await repoRegistry.get(repoName)
if (repo) {
repos.push({ ...repo, local: true })
} else {
repos.push({ name: repoName, local: false, commitCount: 0, description: '' })
}
}
// Build server info for linked servers section
let serverInfo = { writers: [], selfKey: null }
if (namespaceRegistry) {
serverInfo.writers = await namespaceRegistry.listWriters().catch(() => [])
}
if (serverIdentity) {
serverInfo.selfKey = serverIdentity.getPublicKeyHex()
}
// Load access grants for this group
let grants = []
if (namespaceRegistry) {
grants = await namespaceRegistry.listGrants({
grantType: 'group',
groupName: group.name
}).catch(() => [])
}
// Build federation peers from Namespace Registry writers (the actual source of truth)
// Writers are the servers sharing this Autobase — they have the serverKeys we need for punch:// URLs
let federationPeers = []
const selfKey = serverIdentity ? serverIdentity.getPublicKeyHex() : null
if (serverInfo.writers.length > 0) {
for (const w of serverInfo.writers) {
if (w.removedAt) continue
if (w.serverKey && w.serverKey !== selfKey) {
federationPeers.push({ serverKey: w.serverKey, role: w.role, status: 'active' })
}
}
}
const content = renderGroupPage(group, repos, allRepos || [], pathPrefix || '', serverInfo, grants, federationPeers)
html(res, content)
} catch (err) {
console.error('[Groups] Error rendering group page:', err)
html(res, '<h1>Error loading group</h1>', 500)
}
}
// ========================================
// API Handlers
// ========================================
async function handleListNamespaces (req, res, server) {
try {
// Parse query params from URL
const url = new URL(req.url, 'http://localhost')
const type = url.searchParams.get('type')
const filters = {}
if (type) filters.type = type
const namespaces = await listNamespaces(server, filters)
json(res, namespaces)
} catch (err) {
console.error('[API] List namespaces error:', err)
json(res, { error: err.message }, 500)
}
}
async function handleGetNamespace (req, res, server) {
try {
const { name } = req.params
const result = await getNamespace(server, name)
if (!result) {
notFound(res, `Namespace "${name}" not found`)
return
}
json(res, result)
} catch (err) {
console.error('[API] Get namespace error:', err)
json(res, { error: err.message }, 500)
}
}
async function handleCreateNamespace (req, res, server) {
const { namespaceManager, namespaceRegistry } = server.namespaceData
try {
const body = await readJson(req)
const { name, type, displayName, description } = body
if (!name) {
badRequest(res, 'name is required')
return
}
// Write to shared MetadataBase when available (replicates to peers)
const backend = namespaceRegistry || namespaceManager
const result = await backend.create(name, type || 'group', {
displayName,
description
})
json(res, result, 201)
} catch (err) {
console.error('[API] Create namespace error:', err)
json(res, { error: err.message }, 400)
}
}
async function handleReconsolidate (req, res, server) {
const { namespaceManager, namespaceRegistry } = server.namespaceData
const { repos } = server.repoData
try {
// Prefer shared MetadataBase for reconsolidation (replicates to peers)
const backend = namespaceRegistry || namespaceManager
const result = await backend.reconsolidateGroups(repos || [])
console.log('[Groups] Reconsolidated:', result)
json(res, { success: true, ...result })
} catch (err) {
console.error('[API] Reconsolidate error:', err)
json(res, { error: err.message }, 500)
}
}
async function handleUpdateNamespace (req, res, server) {
try {
const { name } = req.params
const updates = await readJson(req)
const backend = await getBackendFor(server, name)
const result = await backend.update(name, updates)
json(res, result)
} catch (err) {
console.error('[API] Update namespace error:', err)
json(res, { error: err.message }, 400)
}
}
async function handleDeleteNamespace (req, res, server) {
try {
const { name } = req.params
const backend = await getBackendFor(server, name)
await backend.delete(name)
json(res, { success: true })
} catch (err) {
console.error('[API] Delete namespace error:', err)
json(res, { error: err.message }, 400)
}
}
async function handleAddRepo (req, res, server) {
try {
const { name } = req.params
const { repoName } = await readJson(req)
if (!repoName) {
badRequest(res, 'repoName is required')
return
}
const backend = await getBackendFor(server, name)
const result = await backend.addRepo(name, repoName)
json(res, result)
} catch (err) {
console.error('[API] Add repo to namespace error:', err)
json(res, { error: err.message }, 400)
}
}
async function handleRemoveRepo (req, res, server) {
try {
const { name, repo } = req.params
const backend = await getBackendFor(server, name)
const result = await backend.removeRepo(name, repo)
json(res, result)
} catch (err) {
console.error('[API] Remove repo from namespace error:', err)
json(res, { error: err.message }, 400)
}
}
async function handleAddMember (req, res, server) {
try {
const { name } = req.params
const { userId, role } = await readJson(req)
if (!userId) {
badRequest(res, 'userId is required')
return
}
const backend = await getBackendFor(server, name)
const result = await backend.addMember(name, userId, role || 'member')
json(res, result)
} catch (err) {
console.error('[API] Add member error:', err)
json(res, { error: err.message }, 400)
}
}
async function handleRemoveMember (req, res, server) {
try {
const { name, id } = req.params
const backend = await getBackendFor(server, name)
const result = await backend.removeMember(name, id)
json(res, result)
} catch (err) {
console.error('[API] Remove member error:', err)
json(res, { error: err.message }, 400)
}
}
// ========================================
// Part 3: Server/Device Linking
// ========================================
/**
* GET /join — Redirect to /groups (join is now a modal there)
*/
async function handleJoinPage (req, res, server) {
const { pathPrefix } = server.config
const prefix = pathPrefix || ''
res.writeHead(302, { Location: prefix + '/groups' })
res.end()
}
/**
* POST /api/namespaces/:name/invite (LEGACY)
* Redirects to the new access-invite system.
* Kept for backward compatibility — creates an access invite for the group.
*/
async function handleGroupInviteLegacy (req, res, server) {
const { store, registry } = server.coreData
const { repoRegistry } = server.repoData
const { serverIdentity } = server.p2p
const { name } = req.params
const group = await getNamespace(server, name)
if (!group) return notFound(res, `Group "${name}" not found`)
const userId = req.auth ? (req.auth.userId || req.auth.identityId) : 'anonymous'
try {
// With unified MetadataBase, namespace registry is always available
if (!server.namespaceRegistry) {
const newRegistry = new GroupFacade({
metadataBase: server.metadataBase,
repoRegistry
})
const serverKey = serverIdentity
? serverIdentity.getPublicKeyHex()
: '0'.repeat(64)
// DANGER: serverKey bug — passes Ed25519 identity key, not Hypercore key.
await newRegistry.addWriter(userId, serverKey, 'owner')
await registry.put('config:namespaceRegistry', {
bootstrapKey: newRegistry.bootstrapKeyHex
})
server.namespaceRegistry = newRegistry
announceRegistry(server)
}
// Copy local group into shared registry if needed
if (group.source === 'local') {
await copyGroupToRegistry(server, group)
}
// Create an access invite + blind-pairing transport
const linker = new RepoLinker({ bootstrap: server._bootstrap })
const invite = await server.namespaceRegistry.createAccessInvite({
inviteType: 'group',
targetGroup: name,
permission: 'write',
createdBy: userId
})
const result = await linker.createInvite({
bootstrapKey: server.namespaceRegistry.bootstrapKeyHex,
type: 'access-invite',
inviteId: invite.inviteId,
groupName: name,
permission: 'write'
})
// Handle peer join in background
result.waitForPeer().then(async (joinerInfo) => {
if (joinerInfo && joinerInfo.peerId && joinerInfo.serverKey) {
// DANGER: serverKey bug — passes Ed25519 identity key, not Hypercore key.
server.namespaceRegistry.addWriter(joinerInfo.peerId, joinerInfo.serverKey, 'writer')
.catch(err => console.error('[Groups] Failed to add writer:', err.message))
// Consume invite to create grant
if (joinerInfo.subjectId) {
server.namespaceRegistry.consumeInvite(invite.inviteId, {
subjectType: joinerInfo.subjectType || 'server',
subjectId: joinerInfo.subjectId || joinerInfo.serverKey
}).catch(err => console.error('[Groups] Failed to create grant:', err.message))
}
}
}).catch(() => {
linker.close().catch(() => {})
})
json(res, {
invite: result.inviteHex,
groupName: name,
repoCount: group.repos.length
})
} catch (err) {
console.error('[Groups] Invite error:', err)
json(res, { error: err.message }, 500)
}
}
/**
* POST /api/groups/join (LEGACY)
* Backward-compatible join that handles both old-style and new access invites.
*/
async function handleGroupJoinLegacy (req, res, server) {
const { store, registry } = server.coreData
const { repoRegistry } = server.repoData
const { serverIdentity } = server.p2p
const body = await readJson(req)
const { invite } = body
if (!invite) return badRequest(res, 'invite is required')
const userId = req.auth ? (req.auth.userId || req.auth.identityId) : 'anonymous'
const serverKey = serverIdentity
? serverIdentity.getPublicKeyHex()
: '0'.repeat(64)
const linker = new RepoLinker({ bootstrap: server._bootstrap })
try {
const payload = await linker.joinWithInvite(invite, {
peerId: userId,
serverKey,
subjectType: 'server',
subjectId: serverKey
})
if (!payload.bootstrapKey) {
return badRequest(res, 'Invalid invite — no bootstrap key received')
}
// With unified MetadataBase, namespace registry is always available
if (!server.namespaceRegistry) {
const newRegistry = new GroupFacade({
metadataBase: server.metadataBase,
repoRegistry
})
await registry.put('config:namespaceRegistry', {
bootstrapKey: payload.bootstrapKey
})
server.namespaceRegistry = newRegistry
announceRegistry(server)
}
// Sync to get group data
await server.namespaceRegistry.sync({ timeout: 5000 })
// Consume invite if present (new-style)
if (payload.inviteId) {
try {
await server.namespaceRegistry.consumeInvite(payload.inviteId, {
subjectType: 'server',
subjectId: serverKey
})
} catch (err) {
console.error('[Groups] Failed to consume invite:', err.message)
}
}
let groupInfo = null
if (payload.groupName) {
groupInfo = await server.namespaceRegistry.get(payload.groupName)
}
json(res, {
success: true,
bootstrapKey: payload.bootstrapKey,
groupName: payload.groupName || null,
group: groupInfo
})
} catch (err) {
console.error('[Groups] Join error:', err)
json(res, { error: err.message }, 500)
} finally {
await linker.close()
}
}
/**
* GET /api/groups/:name/sync-status
* Show which repos in this group are being synced, peer status
*/
async function handleGroupSyncStatus (req, res, server) {
const { namespaceRegistry } = server.namespaceData
const { registry } = server.coreData
const { linkedRepos } = server.repoData
const { name } = req.params
const group = await getNamespace(server, name)
if (!group) return notFound(res, `Group "${name}" not found`)
// Get sync config from server registry
let syncConfig = null
try {
const entry = await registry.get(`sync-config:${name}`)
syncConfig = entry ? entry.value : null
} catch {
// No config = sync all
}
const syncedRepos = syncConfig ? syncConfig.repos : group.repos
const isShared = !!namespaceRegistry
// Check which repos are linked (have Autobase overlay)
const repoStatus = group.repos.map(repoName => ({
name: repoName,
syncing: syncedRepos.includes(repoName),
linked: linkedRepos ? linkedRepos.has(repoName) : false
}))
// Get connected writers
let writers = []
if (namespaceRegistry) {
writers = await namespaceRegistry.listWriters().catch(() => [])
}
json(res, {
groupName: name,
shared: isShared,
totalRepos: group.repos.length,
syncingRepos: syncedRepos.length,
repos: repoStatus,
writers,
writerCount: writers.length
})
}
/**
* PATCH /api/groups/:name/sync
* Update which repos in this group to sync.
* Body: { repos: ['repo1', 'repo2'] }
*/
async function handleGroupSyncUpdate (req, res, server) {
const { registry } = server.coreData
const { name } = req.params
const body = await readJson(req)
const group = await getNamespace(server, name)
if (!group) return notFound(res, `Group "${name}" not found`)
if (!body.repos || !Array.isArray(body.repos)) {
return badRequest(res, 'repos array is required')
}
// Validate repos exist in the group
const invalid = body.repos.filter(r => !group.repos.includes(r))
if (invalid.length > 0) {
return badRequest(res, `Repos not in group: ${invalid.join(', ')}`)
}
// Store sync config
await registry.put(`sync-config:${name}`, {
repos: body.repos,
updatedAt: Date.now()
})
json(res, {
success: true,
groupName: name,
syncingRepos: body.repos.length,
repos: body.repos
})
}
// Export for use in registry.js
export { announceRegistry }