OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / routes / access.js

542 lines · javascript ref:
/**
 * Access Routes — Write/Moderator/Owner Grants
 *
 * Layer 2: Control who can modify things. Per-identity OR per-server.
 * Storage: NamespaceRegistry Autobase (grant: and invite: entries).
 *
 * Handles:
 * - POST   /api/groups/:name/access-invite     Create group access invite
 * - POST   /api/repos/:name/access-invite      Create repo access invite
 * - POST   /api/access/join                    Join via invite (creates grant)
 * - GET    /api/access/grants                  List grants (with filters)
 * - GET    /api/access/grants/:grantId         Get single grant
 * - DELETE /api/access/grants/:grantId         Revoke grant
 * - GET    /api/access/invites                 List active invites
 * - DELETE /api/access/invites/:inviteId       Revoke invite
 * - POST   /api/access/request                 Request access to group/repo
 * - GET    /api/access/requests                List access requests
 * - POST   /api/access/requests/:id/approve    Approve a request (creates grant)
 * - POST   /api/access/requests/:id/deny       Deny a request
 */

import { json, badRequest, notFound, readJson } from '../response.js'
import { requireAdmin } from '../middleware/admin.js'
import { requireAuth } from '../middleware/require-auth.js'
import { GroupFacade } from '../facades/group-facade.js'
import { RepoLinker } from '../repo-link/repo-linker.js'

/**
 * Register access routes
 */
export function registerAccessRoutes (router) {
  // Create invites (admin)
  router.post('/api/groups/:name/access-invite', requireAdmin, handleGroupAccessInvite)
  router.post('/api/repos/:name/access-invite', requireAdmin, handleRepoAccessInvite)

  // Join via invite (authenticated)
  router.post('/api/access/join', requireAuth, handleAccessJoin)

  // Manage grants (admin)
  router.get('/api/access/grants', requireAuth, handleListGrants)
  router.get('/api/access/grants/:grantId', requireAuth, handleGetGrant)
  router.delete('/api/access/grants/:grantId', requireAdmin, handleRevokeGrant)

  // Manage invites (admin)
  router.get('/api/access/invites', requireAuth, handleListInvites)
  router.delete('/api/access/invites/:inviteId', requireAdmin, handleRevokeInvite)

  // Access requests (authenticated users can request, admin approves/denies)
  router.post('/api/access/request', requireAuth, handleAccessRequest)
  router.get('/api/access/requests', requireAuth, handleListRequests)
  router.post('/api/access/requests/:requestId/approve', requireAdmin, handleApproveRequest)
  router.post('/api/access/requests/:requestId/deny', requireAdmin, handleDenyRequest)
}

/**
 * Ensure namespace registry exists, auto-create if needed
 */
async function ensureRegistry (server, userId) {
  const { namespaceRegistry } = server.namespaceData
  if (namespaceRegistry) return namespaceRegistry

  const { store } = server.coreData
  const { repoRegistry } = server.repoData
  const { registry } = server.coreData
  const { serverIdentity, driveSwarm } = server.p2p

  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

  // Announce for replication
  if (newRegistry.discoveryKey && driveSwarm) {
    driveSwarm.join(newRegistry.discoveryKey)
  }

  return newRegistry
}

/**
 * POST /api/groups/:name/access-invite
 * Create an invite for write/moderator/owner access to a group.
 * Body: { permission: 'write'|'moderator'|'owner', forServer?: boolean, maxUses?: number }
 */
async function handleGroupAccessInvite (req, res, server) {
  const { namespaceManager } = server.namespaceData
  const { name } = req.params
  const body = await readJson(req)
  const permission = body.permission || 'write'

  if (!['write', 'moderator', 'owner'].includes(permission)) {
    return badRequest(res, 'permission must be write, moderator, or owner')
  }

  const userId = req.auth ? (req.auth.userId || req.auth.identityId) : 'anonymous'

  try {
    const registry = await ensureRegistry(server, userId)

    // Verify group exists
    const group = await registry.get(name)
    if (!group) {
      // Check local namespace manager
      const local = await namespaceManager.get(name)
      if (!local) return notFound(res, `Group "${name}" not found`)
    }

    // Create invite in registry
    const invite = await registry.createAccessInvite({
      inviteType: 'group',
      targetGroup: name,
      permission,
      maxUses: body.maxUses || 1,
      createdBy: userId
    })

    // Create blind-pairing invite for P2P transport
    const linker = new RepoLinker({ bootstrap: server._bootstrap })
    const result = await linker.createInvite({
      type: 'access-invite',
      inviteId: invite.inviteId,
      bootstrapKey: registry.bootstrapKeyHex,
      groupName: name,
      permission
    })

    // Handle peer join in background
    result.waitForPeer().then(async (joinerInfo) => {
      if (joinerInfo && joinerInfo.subjectId) {
        try {
          await registry.consumeInvite(invite.inviteId, {
            subjectType: joinerInfo.subjectType || 'user',
            subjectId: joinerInfo.subjectId
          })
          console.log(`[Access] Grant created for ${joinerInfo.subjectId.slice(0, 16)}... on group "${name}"`)
        } catch (err) {
          console.error('[Access] Failed to create grant from invite:', err.message)
        }
      }
    }).catch(() => {
      linker.close().catch(() => {})
    })

    json(res, {
      inviteId: invite.inviteId,
      invite: result.inviteHex,
      groupName: name,
      permission
    })
  } catch (err) {
    console.error('[Access] Group invite error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * POST /api/repos/:name/access-invite
 * Create an invite for write/moderator/owner access to a specific repo.
 * Body: { permission: 'write'|'moderator'|'owner', maxUses?: number }
 */
async function handleRepoAccessInvite (req, res, server) {
  const { repos } = server.repoData
  const { name } = req.params
  const body = await readJson(req)
  const permission = body.permission || 'write'

  if (!['write', 'moderator', 'owner'].includes(permission)) {
    return badRequest(res, 'permission must be write, moderator, or owner')
  }

  // Verify repo exists
  const repo = repos.find(r => r.name === name)
  if (!repo) return notFound(res, `Repository "${name}" not found`)

  const userId = req.auth ? (req.auth.userId || req.auth.identityId) : 'anonymous'

  try {
    const registry = await ensureRegistry(server, userId)

    const invite = await registry.createAccessInvite({
      inviteType: 'repo',
      targetRepo: name,
      permission,
      maxUses: body.maxUses || 1,
      createdBy: userId
    })

    // Create blind-pairing invite for P2P transport
    const linker = new RepoLinker({ bootstrap: server._bootstrap })
    const result = await linker.createInvite({
      type: 'access-invite',
      inviteId: invite.inviteId,
      bootstrapKey: registry.bootstrapKeyHex,
      repoName: name,
      permission
    })

    result.waitForPeer().then(async (joinerInfo) => {
      if (joinerInfo && joinerInfo.subjectId) {
        try {
          await registry.consumeInvite(invite.inviteId, {
            subjectType: joinerInfo.subjectType || 'user',
            subjectId: joinerInfo.subjectId
          })
          console.log(`[Access] Grant created for ${joinerInfo.subjectId.slice(0, 16)}... on repo "${name}"`)
        } catch (err) {
          console.error('[Access] Failed to create grant from invite:', err.message)
        }
      }
    }).catch(() => {
      linker.close().catch(() => {})
    })

    json(res, {
      inviteId: invite.inviteId,
      invite: result.inviteHex,
      repoName: name,
      permission
    })
  } catch (err) {
    console.error('[Access] Repo invite error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * POST /api/access/join
 * Join using an access invite.
 * Body: { invite: string }
 */
async function handleAccessJoin (req, res, server) {
  const { serverIdentity, driveSwarm } = server.p2p
  const { store, registry } = server.coreData
  const { repoRegistry } = server.repoData
  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) : null
  if (!userId) return badRequest(res, 'Authentication required')

  const serverKey = serverIdentity
    ? serverIdentity.getPublicKeyHex()
    : null

  const linker = new RepoLinker({ bootstrap: server._bootstrap })

  try {
    const payload = await linker.joinWithInvite(invite, {
      subjectType: body.asServer ? 'server' : 'user',
      subjectId: body.asServer ? serverKey : userId,
      peerId: userId,
      serverKey
    })

    if (!payload.bootstrapKey || !payload.inviteId) {
      return badRequest(res, 'Invalid access invite')
    }

    // Connect to registry if needed
    if (!server.namespaceRegistry) {
      const newRegistry = new GroupFacade({
        metadataBase: server.metadataBase,
        repoRegistry
      })

      await registry.put('config:namespaceRegistry', {
        bootstrapKey: payload.bootstrapKey
      })

      server.namespaceRegistry = newRegistry

      if (newRegistry.discoveryKey && driveSwarm) {
        driveSwarm.join(newRegistry.discoveryKey)
      }
    }

    // Sync to get invite data
    await server.namespaceRegistry.sync({ timeout: 5000 })

    // Consume the invite to create the grant
    const grant = await server.namespaceRegistry.consumeInvite(payload.inviteId, {
      subjectType: body.asServer ? 'server' : 'user',
      subjectId: body.asServer ? serverKey : userId
    })

    json(res, {
      success: true,
      grant,
      groupName: payload.groupName || null,
      repoName: payload.repoName || null
    })
  } catch (err) {
    console.error('[Access] Join error:', err)
    json(res, { error: err.message }, 500)
  } finally {
    await linker.close()
  }
}

/**
 * GET /api/access/grants
 * List grants. Query params: subjectType, subjectId, grantType, groupName, repoName
 */
async function handleListGrants (req, res, server) {
  const { namespaceRegistry } = server.namespaceData
  if (!namespaceRegistry) {
    return json(res, { grants: [], count: 0 })
  }

  try {
    const url = new URL(req.url, 'http://localhost')
    const filters = {}
    if (url.searchParams.get('subjectType')) filters.subjectType = url.searchParams.get('subjectType')
    if (url.searchParams.get('subjectId')) filters.subjectId = url.searchParams.get('subjectId')
    if (url.searchParams.get('grantType')) filters.grantType = url.searchParams.get('grantType')
    if (url.searchParams.get('groupName')) filters.groupName = url.searchParams.get('groupName')
    if (url.searchParams.get('repoName')) filters.repoName = url.searchParams.get('repoName')

    const grants = await namespaceRegistry.listGrants(filters)
    json(res, { grants, count: grants.length })
  } catch (err) {
    console.error('[Access] List grants error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * GET /api/access/grants/:grantId
 */
async function handleGetGrant (req, res, server) {
  const { namespaceRegistry } = server.namespaceData
  if (!namespaceRegistry) {
    return notFound(res, 'No shared registry active')
  }

  const { grantId } = req.params
  const grant = await namespaceRegistry.getGrant(grantId)

  if (!grant) return notFound(res, 'Grant not found')
  json(res, grant)
}

/**
 * DELETE /api/access/grants/:grantId
 * Revoke a grant.
 */
async function handleRevokeGrant (req, res, server) {
  const { namespaceRegistry } = server.namespaceData
  if (!namespaceRegistry) {
    return badRequest(res, 'No shared registry active')
  }

  const { grantId } = req.params
  const revokedBy = req.auth ? (req.auth.userId || req.auth.identityId) : null

  try {
    await namespaceRegistry.revokeGrant(grantId, revokedBy)
    json(res, { success: true, grantId })
  } catch (err) {
    console.error('[Access] Revoke grant error:', err)
    json(res, { error: err.message }, 400)
  }
}

/**
 * GET /api/access/invites
 * List active invites.
 */
async function handleListInvites (req, res, server) {
  const { namespaceRegistry } = server.namespaceData
  if (!namespaceRegistry) {
    return json(res, { invites: [], count: 0 })
  }

  try {
    const invites = await namespaceRegistry.listInvites()
    json(res, { invites, count: invites.length })
  } catch (err) {
    console.error('[Access] List invites error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * DELETE /api/access/invites/:inviteId
 * Revoke an invite.
 */
async function handleRevokeInvite (req, res, server) {
  const { namespaceRegistry } = server.namespaceData
  if (!namespaceRegistry) {
    return badRequest(res, 'No shared registry active')
  }

  const { inviteId } = req.params

  try {
    await namespaceRegistry.revokeAccessInvite(inviteId)
    json(res, { success: true, inviteId })
  } catch (err) {
    console.error('[Access] Revoke invite error:', err)
    json(res, { error: err.message }, 400)
  }
}

/**
 * POST /api/access/request
 * Request access to a group or repo.
 * Body: { targetType: 'group'|'repo', targetName: string, permission: string, reason?: string, asServer?: boolean }
 */
async function handleAccessRequest (req, res, server) {
  const { namespaceRegistry } = server.namespaceData
  const { serverIdentity } = server.p2p
  if (!namespaceRegistry) {
    return badRequest(res, 'No shared registry active — server must be federated first')
  }

  const body = await readJson(req)
  const { targetType, targetName, permission, reason, asServer } = body

  if (!targetType || !['group', 'repo'].includes(targetType)) {
    return badRequest(res, 'targetType must be "group" or "repo"')
  }
  if (!targetName) return badRequest(res, 'targetName is required')
  if (!permission || !['write', 'moderator', 'owner'].includes(permission)) {
    return badRequest(res, 'permission must be write, moderator, or owner')
  }

  const userId = req.auth ? (req.auth.userId || req.auth.identityId) : null
  if (!userId) return badRequest(res, 'Authentication required')

  const serverKey = serverIdentity
    ? serverIdentity.getPublicKeyHex()
    : null

  try {
    const request = await namespaceRegistry.createAccessRequest({
      subjectType: asServer ? 'server' : 'user',
      subjectId: asServer ? serverKey : userId,
      requestType: targetType,
      groupName: targetType === 'group' ? targetName : null,
      repoName: targetType === 'repo' ? targetName : null,
      permission,
      reason: reason || null
    })

    console.log(`[Access] Request created: ${request.requestId} for ${targetType} "${targetName}"`)
    json(res, { success: true, request })
  } catch (err) {
    console.error('[Access] Request error:', err)
    json(res, { error: err.message }, 400)
  }
}

/**
 * GET /api/access/requests
 * List access requests. Query: status, subjectId, requestType, groupName, repoName
 */
async function handleListRequests (req, res, server) {
  const { namespaceRegistry } = server.namespaceData
  if (!namespaceRegistry) {
    return json(res, { requests: [], count: 0 })
  }

  try {
    const url = new URL(req.url, 'http://localhost')
    const filters = {}
    if (url.searchParams.get('status')) filters.status = url.searchParams.get('status')
    if (url.searchParams.get('subjectId')) filters.subjectId = url.searchParams.get('subjectId')
    if (url.searchParams.get('requestType')) filters.requestType = url.searchParams.get('requestType')
    if (url.searchParams.get('groupName')) filters.groupName = url.searchParams.get('groupName')
    if (url.searchParams.get('repoName')) filters.repoName = url.searchParams.get('repoName')

    const requests = await namespaceRegistry.listAccessRequests(filters)
    json(res, { requests, count: requests.length })
  } catch (err) {
    console.error('[Access] List requests error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * POST /api/access/requests/:requestId/approve
 * Approve a pending access request (admin only).
 */
async function handleApproveRequest (req, res, server) {
  const { namespaceRegistry } = server.namespaceData
  if (!namespaceRegistry) {
    return badRequest(res, 'No shared registry active')
  }

  const { requestId } = req.params
  const approvedBy = req.auth ? (req.auth.userId || req.auth.identityId) : null

  try {
    const grant = await namespaceRegistry.approveAccessRequest(requestId, approvedBy)
    console.log(`[Access] Request ${requestId} approved → grant ${grant.grantId}`)
    json(res, { success: true, grant })
  } catch (err) {
    console.error('[Access] Approve request error:', err)
    json(res, { error: err.message }, 400)
  }
}

/**
 * POST /api/access/requests/:requestId/deny
 * Deny a pending access request (admin only).
 * Body: { reason?: string }
 */
async function handleDenyRequest (req, res, server) {
  const { namespaceRegistry } = server.namespaceData
  if (!namespaceRegistry) {
    return badRequest(res, 'No shared registry active')
  }

  const { requestId } = req.params
  const body = await readJson(req)
  const deniedBy = req.auth ? (req.auth.userId || req.auth.identityId) : null

  try {
    await namespaceRegistry.denyAccessRequest(requestId, deniedBy, body.reason || null)
    console.log(`[Access] Request ${requestId} denied`)
    json(res, { success: true, requestId })
  } catch (err) {
    console.error('[Access] Deny request error:', err)
    json(res, { error: err.message }, 400)
  }
}