OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / apply / group.js

460 lines · javascript ref:
/**
 * Group/namespace apply handler for unified MetadataBase
 *
 * Extracted from lib/namespace-registry/apply.js
 * Processes single group operations within the unified apply batch.
 *
 * Key renames from original:
 *   namespace:{name} → group:{name}
 *   writer:{peerId}  → peer:{peerId}
 *   meta:{key}       → group-meta:{key}
 *
 * Key schema (unified):
 *   group:{name}                     → { name, displayName, description, type, userId, members, repos, visibility, createdAt, updatedAt }
 *   peer:{peerId}                    → { role, serverKey, addedBy, addedAt }
 *   grant:{grantId}                  → { grantId, subjectType, subjectId, grantType, groupName, repoName, permission, ... }
 *   invite:{inviteId}                → { inviteId, inviteType, targetGroup, targetRepo, permission, maxUses, ... }
 *   request:{requestId}              → { requestId, subjectType, subjectId, requestType, groupName, repoName, permission, status, ... }
 *   user-grants:{userId}:{grantId}   → { grantId }
 *   server-grants:{serverId}:{grantId} → { grantId }
 *   repo-grants:{repoName}:{grantId}  → { grantId }
 *   group-grants:{groupName}:{grantId} → { grantId }
 *   group-meta:{key}                 → { value }
 */

/**
 * Apply a single group-namespace operation
 *
 * @param {object} op - Operation with { ns: 'group', type, ... }
 * @param {object} batch - Hyperbee batch (caller flushes)
 * @param {object} base - Autobase host (for addWriter/removeWriter)
 */
export async function applyGroupOp (op, batch, base) {
  switch (op.type) {
    case 'create-namespace': {
      // Idempotent — skip if group already exists (handles concurrent create)
      const existing = await batch.get(`group:${op.name}`)
      if (existing) break

      await batch.put(`group:${op.name}`, {
        name: op.name,
        displayName: op.displayName || op.name,
        description: op.description || '',
        type: op.nsType,
        userId: op.nsType === 'user' ? (op.userId || null) : null,
        members: op.members || [],
        repos: [],
        visibility: op.visibility || 'public',
        createdAt: op.timestamp,
        updatedAt: op.timestamp
      })
      break
    }

    case 'update-namespace': {
      const entry = await batch.get(`group:${op.name}`)
      if (!entry) break

      const ns = entry.value
      if (op.displayName !== undefined) ns.displayName = op.displayName
      if (op.description !== undefined) ns.description = op.description
      if (op.visibility !== undefined) ns.visibility = op.visibility
      ns.updatedAt = op.timestamp

      await batch.put(`group:${op.name}`, ns)
      break
    }

    case 'delete-namespace': {
      const entry = await batch.get(`group:${op.name}`)
      if (!entry) break

      // Only delete if repos[] is empty
      if (entry.value.repos.length > 0) break

      await batch.del(`group:${op.name}`)
      break
    }

    case 'add-repo': {
      const nsEntry = await batch.get(`group:${op.namespaceName}`)
      if (!nsEntry) break

      // Scan all groups to remove repo from previous home
      for await (const entry of batch.createReadStream({
        gt: 'group:',
        lt: 'group:\xff'
      })) {
        const other = entry.value
        if (other.name !== op.namespaceName && other.repos.includes(op.repoName)) {
          other.repos = other.repos.filter(r => r !== op.repoName)
          other.updatedAt = op.timestamp
          await batch.put(`group:${other.name}`, other)
        }
      }

      // Re-read after potential modifications
      const nsRefresh = await batch.get(`group:${op.namespaceName}`)
      const ns = nsRefresh.value

      if (!ns.repos.includes(op.repoName)) {
        ns.repos.push(op.repoName)
        ns.updatedAt = op.timestamp
        await batch.put(`group:${op.namespaceName}`, ns)
      }
      break
    }

    case 'remove-repo': {
      const entry = await batch.get(`group:${op.namespaceName}`)
      if (!entry) break

      const ns = entry.value
      ns.repos = ns.repos.filter(r => r !== op.repoName)
      ns.updatedAt = op.timestamp
      await batch.put(`group:${op.namespaceName}`, ns)
      break
    }

    case 'move-repo': {
      const fromEntry = await batch.get(`group:${op.fromNamespace}`)
      const toEntry = await batch.get(`group:${op.toNamespace}`)
      if (!fromEntry || !toEntry) break

      const from = fromEntry.value
      const to = toEntry.value

      from.repos = from.repos.filter(r => r !== op.repoName)
      from.updatedAt = op.timestamp
      await batch.put(`group:${op.fromNamespace}`, from)

      if (!to.repos.includes(op.repoName)) {
        to.repos.push(op.repoName)
      }
      to.updatedAt = op.timestamp
      await batch.put(`group:${op.toNamespace}`, to)
      break
    }

    case 'add-member': {
      const entry = await batch.get(`group:${op.namespaceName}`)
      if (!entry) break

      const ns = entry.value
      const idx = ns.members.findIndex(m => m.userId === op.memberId)
      if (idx >= 0) {
        // Upsert — update role
        ns.members[idx].role = op.role
      } else {
        ns.members.push({
          userId: op.memberId,
          role: op.role,
          addedAt: op.timestamp
        })
      }
      ns.updatedAt = op.timestamp
      await batch.put(`group:${op.namespaceName}`, ns)
      break
    }

    case 'remove-member': {
      const entry = await batch.get(`group:${op.namespaceName}`)
      if (!entry) break

      const ns = entry.value
      ns.members = ns.members.filter(m => m.userId !== op.memberId)
      ns.updatedAt = op.timestamp
      await batch.put(`group:${op.namespaceName}`, ns)
      break
    }

    case 'add-writer': {
      // Deduplicate: check if a peer with the same serverKey already exists
      if (op.serverKey) {
        let existingPeerId = null
        for await (const entry of batch.createReadStream({ gt: 'peer:', lt: 'peer:\xff' })) {
          if (entry.value.serverKey === op.serverKey && !entry.value.removedAt) {
            existingPeerId = entry.key.slice('peer:'.length)
            break
          }
        }
        // If same serverKey exists under a different peerId, remove the old entry
        if (existingPeerId && existingPeerId !== op.peerId) {
          await batch.del(`peer:${existingPeerId}`)
        }
      }

      await batch.put(`peer:${op.peerId}`, {
        role: op.role,
        serverKey: op.serverKey,
        addedBy: op.addedBy || null,
        addedAt: op.timestamp || Date.now()
      })
      if (op.serverKey && op.role !== 'mirror') {
        try {
          await base.addWriter(Buffer.from(op.serverKey, 'hex'), {
            indexer: op.role === 'owner'
          })
        } catch (err) {
          console.error(`[GroupApply] add-writer failed for ${op.serverKey.slice(0, 16)}...: ${err.message}`)
        }
      }
      break
    }

    case 'remove-writer': {
      const existing = await batch.get(`peer:${op.peerId}`)
      if (existing) {
        const writerData = existing.value
        // Mark as removed instead of deleting — allows peers to detect revocation
        writerData.removedAt = op.timestamp || Date.now()
        await batch.put(`peer:${op.peerId}`, writerData)
        if (writerData.serverKey) {
          try {
            await base.removeWriter(Buffer.from(writerData.serverKey, 'hex'))
          } catch (err) {
            console.error(`[GroupApply] remove-writer failed for ${writerData.serverKey.slice(0, 16)}...: ${err.message}`)
          }
        }
      }
      break
    }

    case 'create-grant': {
      const grantKey = `grant:${op.grantId}`
      const existing = await batch.get(grantKey)
      if (existing) break // idempotent

      const grant = {
        grantId: op.grantId,
        subjectType: op.subjectType,
        subjectId: op.subjectId,
        grantType: op.grantType,
        groupName: op.groupName || null,
        repoName: op.repoName || null,
        permission: op.permission,
        grantedBy: op.grantedBy || null,
        grantedAt: op.timestamp,
        revokedAt: null,
        revokedBy: null,
        inviteId: op.inviteId || null
      }

      await batch.put(grantKey, grant)

      // Build indexes for efficient lookups
      if (op.subjectType === 'user') {
        await batch.put(`user-grants:${op.subjectId}:${op.grantId}`, { grantId: op.grantId })
      } else {
        await batch.put(`server-grants:${op.subjectId}:${op.grantId}`, { grantId: op.grantId })
      }

      if (op.grantType === 'repo') {
        await batch.put(`repo-grants:${op.repoName}:${op.grantId}`, { grantId: op.grantId })
      } else {
        await batch.put(`group-grants:${op.groupName}:${op.grantId}`, { grantId: op.grantId })
      }
      break
    }

    case 'revoke-grant': {
      const grantKey = `grant:${op.grantId}`
      const entry = await batch.get(grantKey)
      if (!entry) break

      const grant = entry.value
      if (grant.revokedAt) break // already revoked

      grant.revokedAt = op.timestamp
      grant.revokedBy = op.revokedBy || null
      await batch.put(grantKey, grant)
      break
    }

    case 'create-invite': {
      const inviteKey = `invite:${op.inviteId}`
      const existing = await batch.get(inviteKey)
      if (existing) break // idempotent

      await batch.put(inviteKey, {
        inviteId: op.inviteId,
        inviteType: op.inviteType,
        targetGroup: op.targetGroup || null,
        targetRepo: op.targetRepo || null,
        permission: op.permission,
        maxUses: op.maxUses || 1,
        usedCount: 0,
        createdBy: op.createdBy || null,
        createdAt: op.timestamp,
        expiresAt: op.expiresAt || null,
        revoked: false
      })
      break
    }

    case 'consume-invite': {
      const inviteKey = `invite:${op.inviteId}`
      const inviteEntry = await batch.get(inviteKey)
      if (!inviteEntry) break

      const invite = inviteEntry.value
      if (invite.revoked) break
      if (invite.maxUses > 0 && invite.usedCount >= invite.maxUses) break
      if (invite.expiresAt && op.timestamp > invite.expiresAt) break

      // Increment usage
      invite.usedCount++
      await batch.put(inviteKey, invite)

      // Create the grant from the invite
      const grantKey = `grant:${op.grantId}`
      const existingGrant = await batch.get(grantKey)
      if (existingGrant) break // idempotent

      const grant = {
        grantId: op.grantId,
        subjectType: op.subjectType,
        subjectId: op.subjectId,
        grantType: invite.inviteType,
        groupName: invite.targetGroup || null,
        repoName: invite.targetRepo || null,
        permission: invite.permission,
        grantedBy: invite.createdBy || null,
        grantedAt: op.timestamp,
        revokedAt: null,
        revokedBy: null,
        inviteId: op.inviteId
      }

      await batch.put(grantKey, grant)

      // Build indexes
      if (op.subjectType === 'user') {
        await batch.put(`user-grants:${op.subjectId}:${op.grantId}`, { grantId: op.grantId })
      } else {
        await batch.put(`server-grants:${op.subjectId}:${op.grantId}`, { grantId: op.grantId })
      }

      if (invite.inviteType === 'repo') {
        await batch.put(`repo-grants:${invite.targetRepo}:${op.grantId}`, { grantId: op.grantId })
      } else {
        await batch.put(`group-grants:${invite.targetGroup}:${op.grantId}`, { grantId: op.grantId })
      }
      break
    }

    case 'revoke-invite': {
      const inviteKey = `invite:${op.inviteId}`
      const entry = await batch.get(inviteKey)
      if (!entry) break

      const invite = entry.value
      invite.revoked = true
      await batch.put(inviteKey, invite)
      break
    }

    case 'create-access-request': {
      const reqKey = `request:${op.requestId}`
      const existing = await batch.get(reqKey)
      if (existing) break // idempotent

      await batch.put(reqKey, {
        requestId: op.requestId,
        subjectType: op.subjectType,
        subjectId: op.subjectId,
        requestType: op.requestType,
        groupName: op.groupName || null,
        repoName: op.repoName || null,
        permission: op.permission,
        reason: op.reason || null,
        status: 'pending',
        createdAt: op.timestamp,
        respondedAt: null,
        respondedBy: null,
        denyReason: null
      })

      // Build indexes
      const target = op.requestType === 'group' ? op.groupName : op.repoName
      await batch.put(`${op.requestType}-requests:${target}:${op.requestId}`, { requestId: op.requestId })
      if (op.subjectType === 'user') {
        await batch.put(`user-requests:${op.subjectId}:${op.requestId}`, { requestId: op.requestId })
      } else {
        await batch.put(`server-requests:${op.subjectId}:${op.requestId}`, { requestId: op.requestId })
      }
      break
    }

    case 'approve-access-request': {
      const reqKey = `request:${op.requestId}`
      const reqEntry = await batch.get(reqKey)
      if (!reqEntry) break
      const request = reqEntry.value
      if (request.status !== 'pending') break

      // Mark request as approved
      request.status = 'approved'
      request.respondedAt = op.timestamp
      request.respondedBy = op.approvedBy || null
      await batch.put(reqKey, request)

      // Create the grant from the request
      const grantKey = `grant:${op.grantId}`
      const existingGrant = await batch.get(grantKey)
      if (existingGrant) break

      const grant = {
        grantId: op.grantId,
        subjectType: request.subjectType,
        subjectId: request.subjectId,
        grantType: request.requestType,
        groupName: request.groupName || null,
        repoName: request.repoName || null,
        permission: request.permission,
        grantedBy: op.approvedBy || null,
        grantedAt: op.timestamp,
        revokedAt: null,
        revokedBy: null,
        requestId: op.requestId
      }

      await batch.put(grantKey, grant)

      // Build grant indexes
      if (request.subjectType === 'user') {
        await batch.put(`user-grants:${request.subjectId}:${op.grantId}`, { grantId: op.grantId })
      } else {
        await batch.put(`server-grants:${request.subjectId}:${op.grantId}`, { grantId: op.grantId })
      }

      if (request.requestType === 'repo') {
        await batch.put(`repo-grants:${request.repoName}:${op.grantId}`, { grantId: op.grantId })
      } else {
        await batch.put(`group-grants:${request.groupName}:${op.grantId}`, { grantId: op.grantId })
      }
      break
    }

    case 'deny-access-request': {
      const reqKey = `request:${op.requestId}`
      const reqEntry = await batch.get(reqKey)
      if (!reqEntry) break
      const request = reqEntry.value
      if (request.status !== 'pending') break

      request.status = 'denied'
      request.respondedAt = op.timestamp
      request.respondedBy = op.deniedBy || null
      request.denyReason = op.denyReason || null
      await batch.put(reqKey, request)
      break
    }

    case 'set-meta': {
      await batch.put(`group-meta:${op.key}`, { value: op.value })
      break
    }
  }
}