OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / routes / admin-api.js

1139 lines · javascript ref:
/**
 * Admin API Routes (Extended)
 *
 * These complement the base admin routes in admin.js (dashboard, user listing,
 * identity management, repo management). Routes here provide extended server
 * management that admin.js does not cover.
 *
 * NOTE: GET /api/admin/users and GET /api/admin/identities are registered
 * in admin.js — do NOT register them here (router is first-match-wins).
 *
 * Endpoints:
 * - GET /api/admin/users/:userId - Get user details
 * - PATCH /api/admin/users/:userId - Update user (permissions, name)
 * - POST /api/admin/users/:userId/identities - Add identity to user
 * - DELETE /api/admin/users/:userId/identities/:identityId - Remove identity
 * - GET /api/admin/challenges - List active challenges
 * - POST /api/admin/rate-limit/reset - Reset rate limits
 * - GET /api/admin/stats - Server statistics
 * - POST /api/admin/tokens - Create bearer token
 * - GET /api/admin/tokens - List all tokens
 * - DELETE /api/admin/tokens/:hash - Revoke a token
 * - POST /api/admin/users/merge - Merge two accounts
 * - POST /api/admin/fix/user-identities - Fix user identity arrays
 * - POST /api/admin/users/:userId/deactivate - Deactivate user (blocks auth, preserves data)
 * - POST /api/admin/users/:userId/reactivate - Reactivate deactivated user
 * - POST /api/admin/repair-identity - Force-repair identity record
 * - POST /api/admin/restart - Restart server
 * - POST /api/admin/shutdown - Shutdown server
 * - GET /api/admin/federation/identity-key - Get identity registry bootstrap key for federation
 */

import fs from 'bare-fs'
import path from 'bare-path'
import { json, badRequest, notFound, forbidden, readJson } from '../response.js'
import { requireAdmin } from '../middleware/admin.js'
import { createAttestationForUser } from './identity.js'

/**
 * Register admin API routes (extended — no collisions with admin.js)
 */
export function registerAdminApiRoutes(router) {
  // User detail & update (admin.js only has list + permission upgrade)
  router.get('/api/admin/users/:userId', requireAdmin, handleGetUser)
  router.patch('/api/admin/users/:userId', requireAdmin, handleUpdateUser)

  // Identity linking (admin.js has revoke/restore, these are add/remove)
  router.post('/api/admin/users/:userId/identities', requireAdmin, handleAddIdentity)
  router.delete('/api/admin/users/:userId/identities/:identityId', requireAdmin, handleRemoveIdentity)

  // Server management (unique to this file)
  router.get('/api/admin/challenges', requireAdmin, handleListChallenges)
  router.post('/api/admin/rate-limit/reset', requireAdmin, handleResetRateLimit)
  router.get('/api/admin/stats', requireAdmin, handleGetStats)
  router.post('/api/admin/fix/user-identities', requireAdmin, handleFixUserIdentities)
  router.post('/api/admin/tokens', requireAdmin, handleCreateToken)
  router.get('/api/admin/tokens', requireAdmin, handleListTokens)
  router.delete('/api/admin/tokens/:hash', requireAdmin, handleRevokeToken)
  router.post('/api/admin/users/merge', requireAdmin, handleMergeAccounts)
  router.post('/api/admin/restart', requireAdmin, handleRestart)
  router.post('/api/admin/shutdown', requireAdmin, handleShutdown)

  // User deactivation (blocks auth, preserves all data)
  router.post('/api/admin/users/:userId/deactivate', requireAdmin, handleDeactivateUser)
  router.post('/api/admin/users/:userId/reactivate', requireAdmin, handleReactivateUser)

  // Identity repair (force-create/overwrite for recovery)
  router.post('/api/admin/repair-identity', requireAdmin, handleRepairIdentity)

  // Repository import/delete endpoints
  router.post('/api/admin/repos/scan', requireAdmin, handleScanRepos)
  router.post('/api/admin/repos/import', requireAdmin, handleImportRepo)
  router.patch('/api/admin/repos/:name', requireAdmin, handleUpdateRepo)
  router.delete('/api/admin/repos/:repoName', requireAdmin, handleDeleteRepo)

  // P2P identity replication
  router.get('/api/admin/federation/identity-key', requireAdmin, handleGetFederationIdentityKey)
  router.post('/api/admin/federation/identity-join', requireAdmin, handleJoinIdentityRegistry)
  router.post('/api/admin/federation/identity-add-writer', requireAdmin, handleAddIdentityWriter)
}

/**
 * Get user details
 */
async function handleGetUser(req, res, server) {
  const { authManager } = server.auth
  try {
    const { userId } = req.params
    const registry = authManager.identityRegistry

    const user = await registry.getUser(userId)
    if (!user) {
      notFound(res, 'User not found')
      return
    }

    // Get all identities for this user
    const identities = []
    for await (const entry of registry.db.createReadStream({ gte: 'identity:', lt: 'identity:\xff' })) {
      if (entry.value.userId === userId) {
        identities.push(entry.value)
      }
    }

    json(res, { user, identities })
  } catch (err) {
    console.error('[Admin API] Get user error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Update user (permissions, name, etc.)
 */
async function handleUpdateUser(req, res, server) {
  const { authManager } = server.auth
  try {
    const { userId } = req.params
    const updates = await readJson(req)
    const registry = authManager.identityRegistry

    let user = await registry.getUser(userId)
    if (!user) {
      notFound(res, 'User not found')
      return
    }

    // Apply updates via Autobase
    const profileUpdates = {}
    if (updates.name !== undefined) profileUpdates.name = updates.name
    if (updates.globalPermissions !== undefined) profileUpdates.globalPermissions = updates.globalPermissions
    if (updates.repos !== undefined) profileUpdates.repos = updates.repos

    // Identity array changes should go through link/unlink, but allow direct set for admin
    if (updates.identities !== undefined) profileUpdates.identities = updates.identities

    user = await registry.updateUserProfile(userId, profileUpdates)

    console.log('[Admin API] Updated user:', userId, updates)

    // Include fresh attestation if permissions were changed
    let attestation = null
    if (updates.globalPermissions || updates.repos) {
      const identityId = user.identities && user.identities[0]
      if (identityId) {
        attestation = await createAttestationForUser(server, user, identityId)
      }
    }

    json(res, { success: true, user, attestation })
  } catch (err) {
    console.error('[Admin API] Update user error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Add identity to user
 */
async function handleAddIdentity(req, res, server) {
  const { authManager } = server.auth
  try {
    const { userId } = req.params
    const { identityId, publicKey, method = 'web-identity' } = await readJson(req)
    const registry = authManager.identityRegistry

    const user = await registry.getUser(userId)
    if (!user) {
      notFound(res, 'User not found')
      return
    }

    // Check if public key is already linked to a different user
    if (publicKey) {
      const existingIdentity = await registry.getIdentityByPublicKey(publicKey)
      if (existingIdentity && existingIdentity.userId && existingIdentity.userId !== userId) {
        badRequest(res, 'Public key is already linked to a different user')
        return
      }
    }

    // Create identity via Autobase (handles record, reverse lookups, and user linking)
    const finalIdentityId = identityId || publicKey
    const identity = await registry.createIdentity({
      identityId: finalIdentityId,
      method,
      userId,
      credentials: publicKey ? { publicKey } : {}
    })

    // Refresh user after identity was linked
    const updatedUser = await registry.getUser(userId)
    Object.assign(user, updatedUser)

    console.log('[Admin API] Added identity to user:', userId, identity.identityId)
    json(res, { success: true, identity, user })
  } catch (err) {
    console.error('[Admin API] Add identity error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Remove identity from user
 */
async function handleRemoveIdentity(req, res, server) {
  const { authManager } = server.auth
  try {
    const { userId, identityId } = req.params
    const registry = authManager.identityRegistry

    const user = await registry.getUser(userId)
    if (!user) {
      notFound(res, 'User not found')
      return
    }

    // Unlink identity from user via Autobase
    const decodedIdentityId = decodeURIComponent(identityId)
    try {
      await registry.unlinkIdentity(decodedIdentityId)
    } catch (err) {
      // May already be unlinked — not fatal
      console.warn('[Admin API] Unlink warning:', err.message)
    }

    // Refresh user
    const updatedUser = await registry.getUser(userId)
    Object.assign(user, updatedUser)

    console.log('[Admin API] Removed identity from user:', userId, decodedIdentityId)
    json(res, { success: true, user })
  } catch (err) {
    console.error('[Admin API] Remove identity error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * List active challenges
 */
async function handleListChallenges(req, res, server) {
  const { authManager } = server.auth
  try {
    const challengeManager = authManager.challengeManager
    const stats = await challengeManager.stats()

    const challenges = []
    for (const [id, record] of challengeManager.cache.entries()) {
      challenges.push({
        id,
        expiresAt: new Date(record.expiresAt).toISOString(),
        used: record.used,
        context: record.context,
        ageMs: Date.now() - record.createdAt
      })
    }

    json(res, { stats, challenges })
  } catch (err) {
    console.error('[Admin API] List challenges error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Reset rate limits for an IP or all
 */
async function handleResetRateLimit(req, res, server) {
  const { authManager } = server.auth
  try {
    const { ip } = await readJson(req)
    const rateLimiter = authManager.rateLimiter

    if (ip) {
      // Reset specific IP
      rateLimiter.cache.delete(`auth:${ip}`)
      rateLimiter.cache.delete(`api:${ip}`)
      rateLimiter.cache.delete(`git:${ip}`)
      console.log('[Admin API] Reset rate limit for IP:', ip)
    } else {
      // Reset all
      rateLimiter.cache.clear()
      console.log('[Admin API] Reset all rate limits')
    }

    json(res, { success: true, message: ip ? `Reset rate limit for ${ip}` : 'Reset all rate limits' })
  } catch (err) {
    console.error('[Admin API] Reset rate limit error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Get server statistics
 */
async function handleGetStats(req, res, server) {
  const { authManager } = server.auth
  const { repos } = server.repoData
  try {
    const registry = authManager.identityRegistry
    const challengeManager = authManager.challengeManager

    // Count users and identities (key prefix is user:{userId}, not user:user:)
    let userCount = 0
    let identityCount = 0

    for await (const entry of registry.db.createReadStream({ gte: 'user:', lt: 'user:\xff' })) {
      userCount++
    }
    for await (const entry of registry.db.createReadStream({ gte: 'identity:', lt: 'identity:\xff' })) {
      identityCount++
    }

    const challengeStats = await challengeManager.stats()

    json(res, {
      server: {
        uptime: process.uptime(),
        memory: process.memoryUsage(),
        repos: repos?.length || 0
      },
      registry: {
        users: userCount,
        identities: identityCount
      },
      challenges: challengeStats,
      rateLimiter: {
        cached: authManager.rateLimiter.cache.size
      }
    })
  } catch (err) {
    console.error('[Admin API] Get stats error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Fix user identities array - ensures all identities pointing to a user are in their array
 */
async function handleFixUserIdentities(req, res, server) {
  const { authManager } = server.auth
  try {
    const { userId } = await readJson(req)
    const registry = authManager.identityRegistry

    let usersFixed = 0
    let identitiesAdded = 0

    // If specific user, fix just that user
    const userIds = userId ? [userId] : []

    // Otherwise get all users (key prefix is user:{userId})
    if (!userId) {
      for await (const entry of registry.db.createReadStream({ gte: 'user:', lt: 'user:\xff' })) {
        if (entry.value && entry.value.userId) {
          userIds.push(entry.value.userId)
        }
      }
    }

    for (const uid of userIds) {
      const user = await registry.getUser(uid)
      if (!user) continue

      // Find all identities pointing to this user
      const identitiesForUser = []
      for await (const entry of registry.db.createReadStream({ gte: 'identity:', lt: 'identity:\xff' })) {
        if (entry.value.userId === uid) {
          identitiesForUser.push(entry.value.identityId || entry.value.publicKey || entry.key.replace('identity:', ''))
        }
      }

      // Link any missing identities via Autobase
      let modified = false
      for (const identityId of identitiesForUser) {
        if (!user.identities.includes(identityId)) {
          try {
            await registry.linkIdentity(identityId, uid)
            identitiesAdded++
            modified = true
          } catch (err) {
            console.warn('[Admin API] Fix link warning:', identityId, err.message)
          }
        }
      }

      if (modified) {
        usersFixed++
      }
    }

    console.log('[Admin API] Fixed user identities:', { usersFixed, identitiesAdded })
    json(res, { success: true, usersFixed, identitiesAdded })
  } catch (err) {
    console.error('[Admin API] Fix user identities error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Create a bearer token
 */
async function handleCreateToken(req, res, server) {
  const { authManager } = server.auth
  try {
    const { username, userId, description, permissions, repos, expiresAt } = await readJson(req)
    if (!username) {
      badRequest(res, 'username is required')
      return
    }

    const result = await authManager.bearerProvider.createToken({
      username,
      userId: userId || null,
      description,
      permissions,
      repos,
      expiresAt: expiresAt ? new Date(expiresAt).getTime() : null
    })

    console.log('[Admin API] Created token for:', username, result.tokenHash.slice(0, 8) + '...')
    json(res, { token: result.token, tokenHash: result.tokenHash })
  } catch (err) {
    console.error('[Admin API] Create token error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * List all tokens (masked)
 */
async function handleListTokens(req, res, server) {
  const { authManager } = server.auth
  try {
    const tokens = await authManager.bearerProvider.listAllTokens()

    // Mask sensitive data - only show hash prefix + metadata
    const masked = tokens.map(t => ({
      hashPrefix: t.tokenHash.slice(0, 12) + '...',
      tokenHash: t.tokenHash,
      username: t.username,
      description: t.description,
      permissions: t.permissions,
      repos: t.repos,
      createdAt: t.createdAt,
      expiresAt: t.expiresAt,
      lastUsedAt: t.lastUsedAt,
      usageCount: t.usageCount || 0
    }))

    json(res, { tokens: masked, count: masked.length })
  } catch (err) {
    console.error('[Admin API] List tokens error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Revoke a token by hash
 */
async function handleRevokeToken(req, res, server) {
  const { authManager } = server.auth
  try {
    const { hash } = req.params
    const revoked = await authManager.bearerProvider.revokeToken(hash)

    if (!revoked) {
      notFound(res, 'Token not found')
      return
    }

    console.log('[Admin API] Revoked token:', hash.slice(0, 12) + '...')
    json(res, { success: true })
  } catch (err) {
    console.error('[Admin API] Revoke token error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Merge two user accounts
 */
async function handleMergeAccounts(req, res, server) {
  const { authManager } = server.auth
  const { linkedRepos } = server.repoData
  try {
    const { primaryUserId, secondaryUserId, preview } = await readJson(req)

    if (!primaryUserId || !secondaryUserId) {
      badRequest(res, 'primaryUserId and secondaryUserId are required')
      return
    }

    const registry = authManager.identityRegistry

    if (preview) {
      // Show what would be merged
      const primary = await registry.getUser(primaryUserId)
      const secondary = await registry.getUser(secondaryUserId)

      if (!primary) { notFound(res, 'Primary user not found'); return }
      if (!secondary) { notFound(res, 'Secondary user not found'); return }

      json(res, {
        preview: true,
        primary: { userId: primary.userId, name: primary.name, identities: primary.identities.length, repos: primary.repos.length },
        secondary: { userId: secondary.userId, name: secondary.name, identities: secondary.identities.length, repos: secondary.repos.length },
        willTransfer: { identities: secondary.identities.length, repos: secondary.repos.length }
      })
      return
    }

    const result = await registry.mergeAccounts(primaryUserId, secondaryUserId, {
      linkedRepos
    })

    console.log('[Admin API] Merged accounts:', secondaryUserId, '→', primaryUserId, result.stats)
    json(res, { success: true, primaryUserId, secondaryUserId, stats: result.stats })
  } catch (err) {
    console.error('[Admin API] Merge accounts error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Restart server — dual-mode:
 *   Pear mode (pear run): sidecar teardown + respawn (clean, automatic)
 *   Bare mode (bare):     graceful close + process.exit (needs external supervisor)
 */
async function handleRestart(req, res, server) {
  try {
    const { delay = 500 } = await readJson(req).catch(() => ({}))
    const isPear = typeof Pear !== 'undefined'
    const mode = isPear ? 'pear-sidecar' : 'process-exit'

    console.log(`[Restart] Mode: ${mode}, delay: ${delay}ms`)
    json(res, { success: true, mode, message: `Server restarting in ${delay}ms` })

    setTimeout(async () => {
      if (isPear) {
        // Pear mode: sidecar tears us down and respawns with same args
        console.log('[Restart] Requesting sidecar restart...')
        try {
          const ipc = Pear[Pear.constructor.IPC]
          await ipc.restart()
          // Sidecar handles everything from here — teardown, respawn
          return
        } catch (e) {
          console.error('[Restart] Sidecar restart failed, falling back to exit:', e.message)
        }
      }

      // Bare mode (or sidecar fallback): graceful close + exit
      // External supervisor (systemd, tmux loop) should restart us
      console.log('[Restart] Graceful shutdown...')
      try {
        if (server.close) await server.close()
      } catch (e) {
        console.error('[Restart] Error during close:', e.message)
      }
      process.exit(0)
    }, delay)
  } catch (err) {
    console.error('[Restart] Error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Get identity registry bootstrap key for federation
 *
 * Returns the Autobase bootstrap key that remote servers need
 * to join this server's identity registry.
 */
async function handleGetFederationIdentityKey(req, res, server) {
  const { identityRegistry } = server.auth
  try {
    const registry = identityRegistry
    if (!registry) {
      notFound(res, 'Identity registry not available')
      return
    }

    const bootstrapKey = registry.bootstrapKeyHex
    const discoveryKey = registry.discoveryKey
      ? registry.discoveryKey.toString('hex')
      : null

    if (!bootstrapKey) {
      notFound(res, 'Identity registry key not available (not yet ready?)')
      return
    }

    const localKey = registry.localKey
      ? registry.localKey.toString('hex')
      : null

    json(res, { bootstrapKey, discoveryKey, localKey })
  } catch (err) {
    console.error('[Admin API] Get identity key error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Join a remote identity registry — replaces this server's identity
 * registry with one backed by a shared Autobase bootstrap key.
 *
 * POST /api/admin/federation/identity-join
 * Body: { bootstrapKey: "<hex>" }
 *
 * After joining, both servers share the same identity Autobase.
 * The remote server must also call addWriter with our localKey.
 */
async function handleJoinIdentityRegistry (req, res, server) {
  const { identityRegistry } = server.auth
  const { store, registry } = server.coreData
  try {
    const { bootstrapKey } = await readJson(req)
    if (!bootstrapKey) {
      badRequest(res, 'bootstrapKey is required')
      return
    }

    if (!identityRegistry) {
      notFound(res, 'Identity registry not available')
      return
    }

    // Check if already using this key
    if (identityRegistry.bootstrapKeyHex === bootstrapKey) {
      json(res, {
        success: true,
        message: 'Already using this identity registry',
        localKey: identityRegistry.localKey?.toString('hex')
      })
      return
    }

    // With unified MetadataBase, identity is always a facade — no re-creation needed.
    // The MetadataBase writer exchange handles federation automatically.
    console.log('[Admin] Identity registry swap no longer needed — unified MetadataBase handles federation')

    // Re-announce on Hyperswarm
    await server._initIdentityReplication()

    const localKey = server.auth.identityRegistry.localKey?.toString('hex')
    console.log('[P2P] Joined identity registry:', bootstrapKey.substring(0, 16), 'localKey:', localKey?.substring(0, 16))

    json(res, {
      success: true,
      bootstrapKey,
      localKey,
      message: 'Joined identity registry. Remote server must call addWriter with localKey: ' + localKey
    })
  } catch (err) {
    console.error('[Admin API] Join identity registry error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Add a remote server as a writer to this identity registry
 *
 * POST /api/admin/federation/identity-add-writer
 * Body: { writerKey: "<hex>" }
 */
async function handleAddIdentityWriter (req, res, server) {
  const { identityRegistry } = server.auth
  try {
    const { writerKey } = await readJson(req)
    if (!writerKey) {
      badRequest(res, 'writerKey is required')
      return
    }

    const registry = identityRegistry
    if (!registry) {
      notFound(res, 'Identity registry not available')
      return
    }

    await registry.addWriter(writerKey)

    console.log('[P2P] Added identity writer:', writerKey.substring(0, 16))
    json(res, { success: true, writerKey })
  } catch (err) {
    console.error('[Admin API] Add identity writer error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Scan a directory for git repositories
 */
async function handleScanRepos(req, res, server) {
  const { repoRegistry } = server.repoData
  try {
    const { path: scanDir, maxDepth = 2 } = await readJson(req)

    if (!scanDir) {
      badRequest(res, 'path is required')
      return
    }

    // Validate path exists and is a directory
    let stat
    try {
      stat = await fs.promises.stat(scanDir)
    } catch {
      badRequest(res, 'Path does not exist: ' + scanDir)
      return
    }

    if (!stat.isDirectory()) {
      badRequest(res, 'Path is not a directory: ' + scanDir)
      return
    }

    // Clamp maxDepth to prevent abuse
    const depth = Math.min(Math.max(1, maxDepth), 5)

    // Scan for repos
    const discovered = await repoRegistry.scanPath(scanDir, depth)

    // Filter out repos that are already registered
    const registered = new Set()
    const existingRepos = await repoRegistry.listAll()
    for (const r of existingRepos) {
      registered.add(r.name)
      registered.add(r.path)
    }

    const repos = discovered
      .filter(r => !registered.has(r.name) && !registered.has(r.path))
      .map(r => ({
        name: r.name,
        path: r.path,
        commits: r.commits || 0,
        branches: r.branches || 0,
        isBare: r.isBare !== false
      }))

    json(res, { repos, total: discovered.length, filtered: repos.length })
  } catch (err) {
    console.error('[Admin API] Scan repos error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Import a repository by path or clone URL
 */
async function handleImportRepo(req, res, server) {
  const { repoRegistry, repos, topicManager } = server.repoData
  try {
    const { name, fromPath, cloneUrl, visibility = 'public' } = await readJson(req)

    if (!name) {
      badRequest(res, 'name is required')
      return
    }

    // Validate name
    if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
      badRequest(res, 'Invalid repo name. Use alphanumeric, dots, hyphens, underscores.')
      return
    }

    if (name.startsWith('__') || name.startsWith('.')) {
      badRequest(res, 'Reserved name: ' + name)
      return
    }

    // Check if already exists
    const existing = await repoRegistry.getRepo(name)
    if (existing) {
      badRequest(res, 'Repository already exists: ' + name)
      return
    }

    const repoPath = path.join(repoRegistry.gitReposPath, name + '.git')

    // Check if bare repo already exists at the expected location
    let alreadyExists = false
    try {
      await fs.promises.stat(path.join(repoPath, 'HEAD'))
      alreadyExists = true
    } catch {}

    if (!alreadyExists && !fromPath && !cloneUrl) {
      badRequest(res, 'Either --from-path or --clone-url is required (or place bare repo at ' + repoPath + ')')
      return
    }

    if (alreadyExists) {
      console.log('[Admin API] Bare repo already at', repoPath, '— registering')
    } else if (fromPath) {
      // Validate source path exists
      try {
        await fs.promises.stat(fromPath)
      } catch {
        badRequest(res, 'Source path not found: ' + fromPath)
        return
      }

      // Path traversal check: resolved path must not escape server root
      const resolved = path.resolve(fromPath)
      if (resolved.includes('..')) {
        badRequest(res, 'Invalid path')
        return
      }

      // Bare clone to gitReposPath
      await repoRegistry.execGit(repoRegistry.gitReposPath, [
        'clone', '--bare', fromPath, repoPath
      ])
      console.log('[Admin API] Cloned', fromPath, '->', repoPath)
    } else if (cloneUrl) {
      // Validate URL scheme
      const allowedSchemes = ['https://', 'git://', 'ssh://']
      const hasAllowedScheme = allowedSchemes.some(s => cloneUrl.startsWith(s))
      // Also allow git@host:repo SSH shorthand
      const isSshShorthand = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+:/.test(cloneUrl)

      if (!hasAllowedScheme && !isSshShorthand) {
        badRequest(res, 'Invalid clone URL. Use https://, git://, ssh://, or git@host:repo format.')
        return
      }

      // Clone from URL
      await repoRegistry.execGit(repoRegistry.gitReposPath, [
        'clone', '--bare', cloneUrl, repoPath
      ])
      console.log('[Admin API] Cloned', cloneUrl, '->', repoPath)
    }

    // Get repo info and register
    const repoInfo = await repoRegistry.getRepoInfo(repoPath, name)
    if (!repoInfo) {
      json(res, { error: 'Failed to get repo info after import' }, 500)
      return
    }

    repoInfo.visibility = visibility
    if (req.auth && req.auth.userId) {
      repoInfo.owner = req.auth.userId
    }

    // Ensure post-receive hook is installed for federation sync
    await repoRegistry.ensurePostReceiveHook(repoPath)

    await repoRegistry.register(repoInfo)

    // Update in-memory repos list
    if (repos) {
      repos.push(repoInfo)
    }

    // Announce on DHT
    if (topicManager && visibility !== 'private') {
      try {
        await topicManager.announceRepo(name)
      } catch (err) {
        console.error('[Admin API] DHT announce failed:', err.message)
      }
    }

    // Build punch URL
    let punchUrl = null
    if (topicManager) {
      punchUrl = topicManager.getPunchUrl(name)
    }

    json(res, {
      success: true,
      name,
      path: repoPath,
      punchUrl,
      commits: repoInfo.commits || 0,
      branches: repoInfo.branches || 0,
      visibility
    })
  } catch (err) {
    console.error('[Admin API] Import repo error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Update a repository's settings (visibility, category, etc.)
 */
async function handleUpdateRepo(req, res, server) {
  const { repoRegistry } = server.repoData
  try {
    const name = req.params.name
    const updates = await readJson(req)

    const existing = await repoRegistry.getRepo(name)
    if (!existing) {
      notFound(res, 'Repository not found: ' + name)
      return
    }

    // Apply allowed updates
    if (updates.visibility) existing.visibility = updates.visibility
    if (updates.category !== undefined) existing.category = updates.category

    // Write back to registry
    await repoRegistry.registry.put(`repo:${name}`, existing)

    console.log('[Admin API] Updated repo:', name, updates)
    json(res, { ok: true, repo: { name, visibility: existing.visibility, category: existing.category } })
  } catch (err) {
    console.error('[Admin API] Update repo error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Delete a repository from the registry (and optionally the bare repo on disk)
 */
async function handleDeleteRepo(req, res, server) {
  const { repoRegistry, topicManager } = server.repoData
  try {
    const repoName = req.params.repoName
    if (!repoName) {
      badRequest(res, 'repo name is required')
      return
    }

    const existing = await repoRegistry.get(repoName)
    if (!existing) {
      json(res, { error: 'Repository not found: ' + repoName }, 404)
      return
    }

    // Check for ?deleteBareRepo=true query param
    const deleteBareRepo = req.url.includes('deleteBareRepo=true')

    const result = await repoRegistry.deleteRepo(repoName, { deleteBareRepo })

    // Remove from in-memory repos list
    server.repoData.repos = server.repoData.repos.filter(r => r.name !== repoName)

    // Un-announce from DHT
    if (topicManager) {
      try {
        await topicManager.unannounceRepo(repoName)
      } catch (err) {
        // Ignore — may not have been announced
      }
    }

    // Remove from MetadataBase (prevents federation peers from re-creating it)
    if (server.metadataBase) {
      try {
        await server.metadataBase.del(`repo:${repoName}`)
      } catch (err) {
        // Ignore — may not exist
      }
    }

    json(res, { success: true, ...result })
  } catch (err) {
    console.error('[Admin API] Delete repo error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Shutdown server (no restart)
 */
async function handleShutdown(req, res, server) {
  try {
    const { delay = 1000 } = await readJson(req).catch(() => ({}))

    console.log('[Admin API] Shutdown requested, stopping in', delay, 'ms')
    json(res, { success: true, message: `Server shutting down in ${delay}ms` })

    // Schedule graceful shutdown
    setTimeout(async () => {
      console.log('[Admin API] Performing graceful shutdown...')

      // Write stop file so run-loop.sh doesn't restart
      try {
        fs.writeFileSync(path.join(process.cwd(), '.stop-server'), '')
      } catch (e) {
        console.error('[Admin API] Could not write stop file:', e)
      }

      try {
        if (server.close) await server.close()
      } catch (e) {
        console.error('[Admin API] Error during close:', e)
      }
      process.exit(0)
    }, delay)
  } catch (err) {
    console.error('[Admin API] Shutdown error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Deactivate a user — blocks authentication, preserves all data.
 * Guards: cannot deactivate self, cannot deactivate last admin.
 */
async function handleDeactivateUser (req, res, server) {
  const { authManager } = server.auth
  try {
    const { userId } = req.params
    const { reason } = await readJson(req).catch(() => ({}))
    const registry = authManager.identityRegistry

    // Guard: cannot deactivate yourself
    if (req.auth.userId === userId) {
      forbidden(res, 'Cannot deactivate your own account')
      return
    }

    const user = await registry.getUser(userId)
    if (!user) {
      notFound(res, 'User not found')
      return
    }

    // Guard: cannot deactivate the last active admin
    const isAdmin = (user.globalPermissions || []).includes('admin')
    if (isAdmin) {
      const allUsers = await registry.listAllUsers()
      const activeAdmins = allUsers.filter(u =>
        !u.deactivatedAt &&
        u.userId !== userId &&
        (u.globalPermissions || []).includes('admin')
      )
      if (activeAdmins.length === 0) {
        forbidden(res, 'Cannot deactivate the last active admin')
        return
      }
    }

    const deactivated = await registry.deactivateUser(userId, {
      reason: reason || 'Deactivated by admin',
      deactivatedBy: req.auth.userId || req.auth.identityId
    })

    json(res, { success: true, user: deactivated })
  } catch (err) {
    console.error('[Admin API] Deactivate user error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Reactivate a deactivated user — restores authentication.
 */
async function handleReactivateUser (req, res, server) {
  const { authManager } = server.auth
  try {
    const { userId } = req.params
    const registry = authManager.identityRegistry

    const user = await registry.getUser(userId)
    if (!user) {
      notFound(res, 'User not found')
      return
    }

    const reactivated = await registry.reactivateUser(userId)
    json(res, { success: true, user: reactivated })
  } catch (err) {
    console.error('[Admin API] Reactivate user error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Repair identity — force-create/overwrite identity record, pubkey lookup, and user link.
 * Used to recover from orphaned/revoked state.
 */
async function handleRepairIdentity (req, res, server) {
  const { authManager } = server.auth
  try {
    const { identityId, method, userId, credentials } = await readJson(req)

    if (!identityId || !method || !userId) {
      badRequest(res, 'Required: identityId, method, userId')
      return
    }

    const registry = authManager.identityRegistry
    const user = await registry.getUser(userId)
    if (!user) {
      notFound(res, 'Target user not found')
      return
    }

    const identity = await registry.repairIdentity({
      identityId,
      method,
      userId,
      credentials: credentials || { publicKey: identityId }
    })

    console.log(`[Admin API] Repaired identity ${identityId.substring(0, 12)}... → user ${userId.substring(0, 12)}...`)
    json(res, { success: true, identity, user: await registry.getUser(userId) })
  } catch (err) {
    console.error('[Admin API] Repair identity error:', err)
    json(res, { error: err.message }, 500)
  }
}

export {
  handleGetUser,
  handleUpdateUser,
  handleAddIdentity,
  handleRemoveIdentity,
  handleListChallenges,
  handleResetRateLimit,
  handleGetStats,
  handleFixUserIdentities,
  handleCreateToken,
  handleListTokens,
  handleRevokeToken,
  handleMergeAccounts,
  handleRestart,
  handleShutdown,
  handleScanRepos,
  handleImportRepo,
  handleDeleteRepo,
  handleGetFederationIdentityKey,
  handleJoinIdentityRegistry,
  handleAddIdentityWriter,
  handleDeactivateUser,
  handleReactivateUser,
  handleRepairIdentity
}