OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / routes / namespaces.js

704 lines · javascript ref:
/**
 * 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 }