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