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