OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / apply / identity.js

388 lines · javascript ref:
/**
 * Identity namespace apply handler for unified MetadataBase
 *
 * Extracted from lib/auth/identity-apply.js
 * Processes single identity operations within the unified apply batch.
 *
 * Key schema (unified, no collisions):
 *   user:{userId}          → { userId, username, name, email, profilePicture, globalPermissions, repos, identities, createdAt, updatedAt }
 *   identity:{identityId}  → { identityId, method, userId, credentials, createdAt, updatedAt }
 *   pubkey:{publicKey}     → { identityId }
 *   sshkey:{fingerprint}   → { identityId }
 *   noisekey:{publicKey}   → { identityId }
 *   username:{username}    → { userId }
 *   revoked:{identityId}   → { identityId, revokedAt, revokedBy, reason }
 */

/**
 * Apply a single identity-namespace operation
 *
 * @param {object} op - Operation with { ns: 'identity', type, ... }
 * @param {object} batch - Hyperbee batch (caller flushes)
 * @param {object} base - Autobase host (for addWriter/removeWriter)
 */
export async function applyIdentityOp (op, batch, base) {
  // Handle writer addition (Autobase side-effect, not a view update)
  if (op.type === 'add-writer') {
    if (op.writerKey) {
      const key = typeof op.writerKey === 'string' ? Buffer.from(op.writerKey, 'hex') : op.writerKey
      await base.addWriter(key, { indexer: true })
    }
    return
  }

  switch (op.type) {
    case 'create-user': {
      const existing = await batch.get(`user:${op.userId}`)
      if (existing) break

      const username = op.username || null
      await batch.put(`user:${op.userId}`, {
        userId: op.userId,
        username,
        name: op.name || op.username || 'Anonymous',
        email: op.email || null,
        profilePicture: op.profilePicture || null,
        globalPermissions: op.globalPermissions || ['read'],
        repos: op.repos || [{ name: '*', permissions: ['read'] }],
        identities: [],
        createdAt: op.timestamp,
        updatedAt: op.timestamp
      })
      // Username uniqueness index
      if (username) {
        await batch.put(`username:${username}`, { userId: op.userId })
      }
      break
    }

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

      const user = entry.value
      if (op.username !== undefined && op.username !== user.username) {
        // Remove old username index
        if (user.username) await batch.del(`username:${user.username}`)
        // Add new username index
        if (op.username) await batch.put(`username:${op.username}`, { userId: op.userId })
        user.username = op.username
      }
      if (op.name !== undefined) user.name = op.name
      if (op.profilePicture !== undefined) user.profilePicture = op.profilePicture
      if (op.email !== undefined) user.email = op.email
      if (op.globalPermissions !== undefined) user.globalPermissions = op.globalPermissions
      if (op.repos !== undefined) user.repos = op.repos
      // Arbitrary flags (restoredFromAttestation, etc.)
      if (op.flags) {
        for (const [key, value] of Object.entries(op.flags)) {
          user[key] = value
        }
      }
      user.updatedAt = op.timestamp

      await batch.put(`user:${op.userId}`, user)
      break
    }

    case 'deactivate-user': {
      const entry = await batch.get(`user:${op.userId}`)
      if (!entry) break

      const user = entry.value
      if (user.deactivatedAt) break // already deactivated

      user.deactivatedAt = op.timestamp
      user.deactivatedBy = op.deactivatedBy || 'system'
      user.deactivationReason = op.reason || null
      user.updatedAt = op.timestamp
      await batch.put(`user:${op.userId}`, user)
      break
    }

    case 'reactivate-user': {
      const entry = await batch.get(`user:${op.userId}`)
      if (!entry) break

      const user = entry.value
      if (!user.deactivatedAt) break // not deactivated

      delete user.deactivatedAt
      delete user.deactivatedBy
      delete user.deactivationReason
      user.reactivatedAt = op.timestamp
      user.updatedAt = op.timestamp
      await batch.put(`user:${op.userId}`, user)
      break
    }

    case 'create-identity': {
      const existing = await batch.get(`identity:${op.identityId}`)
      if (existing) break

      const identity = {
        identityId: op.identityId,
        method: op.method,
        userId: op.userId || null,
        credentials: op.credentials || {},
        createdAt: op.timestamp,
        updatedAt: op.timestamp
      }

      await batch.put(`identity:${op.identityId}`, identity)

      // Create reverse lookups
      if (op.method === 'ssh-key') {
        const fingerprint = (op.credentials && op.credentials.fingerprint) || null
        if (fingerprint) {
          await batch.put(`sshkey:${fingerprint}`, { identityId: op.identityId })
        }
      } else if (op.method === 'noise-key') {
        const publicKey = (op.credentials && op.credentials.publicKey) || null
        if (publicKey) {
          await batch.put(`noisekey:${publicKey}`, { identityId: op.identityId })
        }
      } else if (op.method === 'web-identity' || op.method === 'keet-identity') {
        const publicKey = (op.credentials && op.credentials.publicKey) || op.identityId
        await batch.put(`pubkey:${publicKey}`, { identityId: op.identityId })
      }

      // If userId provided, add to user's identities array
      if (op.userId) {
        const userEntry = await batch.get(`user:${op.userId}`)
        if (userEntry) {
          const user = userEntry.value
          if (!user.identities.includes(op.identityId)) {
            user.identities.push(op.identityId)
            user.updatedAt = op.timestamp
            await batch.put(`user:${op.userId}`, user)
          }
        }
      }
      break
    }

    case 'link-identity': {
      const identityEntry = await batch.get(`identity:${op.identityId}`)
      if (!identityEntry) break

      const userEntry = await batch.get(`user:${op.userId}`)
      if (!userEntry) break

      const identity = identityEntry.value
      identity.userId = op.userId
      identity.updatedAt = op.timestamp
      await batch.put(`identity:${op.identityId}`, identity)

      const user = userEntry.value
      if (!user.identities.includes(op.identityId)) {
        user.identities.push(op.identityId)
        user.updatedAt = op.timestamp
        await batch.put(`user:${op.userId}`, user)
      }
      break
    }

    case 'unlink-identity': {
      const identityEntry = await batch.get(`identity:${op.identityId}`)
      if (!identityEntry) break

      const identity = identityEntry.value
      if (!identity.userId) break

      const userEntry = await batch.get(`user:${identity.userId}`)
      if (userEntry) {
        const user = userEntry.value
        user.identities = user.identities.filter(id => id !== op.identityId)
        user.updatedAt = op.timestamp
        await batch.put(`user:${identity.userId}`, user)
      }

      identity.userId = null
      identity.updatedAt = op.timestamp
      await batch.put(`identity:${op.identityId}`, identity)
      break
    }

    case 'revoke-identity': {
      const identityEntry = await batch.get(`identity:${op.identityId}`)
      if (!identityEntry) break

      const identity = identityEntry.value
      if (identity.revoked) break

      identity.revoked = true
      identity.revokedAt = op.timestamp
      identity.revokedBy = op.revokedBy || 'system'
      identity.revocationReason = op.reason || 'No reason provided'
      identity.updatedAt = op.timestamp
      await batch.put(`identity:${op.identityId}`, identity)

      await batch.put(`revoked:${op.identityId}`, {
        identityId: op.identityId,
        revokedAt: op.timestamp,
        revokedBy: identity.revokedBy,
        reason: identity.revocationReason
      })
      break
    }

    case 'restore-identity': {
      const identityEntry = await batch.get(`identity:${op.identityId}`)
      if (!identityEntry) break

      const identity = identityEntry.value
      if (!identity.revoked) break

      delete identity.revoked
      delete identity.revokedAt
      delete identity.revokedBy
      delete identity.revocationReason
      identity.restoredAt = op.timestamp
      identity.updatedAt = op.timestamp
      await batch.put(`identity:${op.identityId}`, identity)

      await batch.del(`revoked:${op.identityId}`)

      // Restore reverse lookup
      if (identity.method === 'web-identity' || identity.method === 'keet-identity') {
        const pk = (identity.credentials && identity.credentials.publicKey) || op.identityId
        await batch.put(`pubkey:${pk}`, { identityId: op.identityId })
      } else if (identity.method === 'ssh-key' && identity.credentials && identity.credentials.fingerprint) {
        await batch.put(`sshkey:${identity.credentials.fingerprint}`, { identityId: op.identityId })
      } else if (identity.method === 'noise-key' && identity.credentials && identity.credentials.publicKey) {
        await batch.put(`noisekey:${identity.credentials.publicKey}`, { identityId: op.identityId })
      }

      // Reactivate the parent user if it was deactivated
      if (identity.userId) {
        const userEntry = await batch.get(`user:${identity.userId}`)
        if (userEntry && userEntry.value.deactivatedAt) {
          const user = userEntry.value
          delete user.deactivatedAt
          delete user.deactivatedBy
          delete user.deactivationReason
          user.reactivatedAt = op.timestamp
          user.updatedAt = op.timestamp
          await batch.put(`user:${identity.userId}`, user)
        }
      }
      break
    }

    case 'repair-identity': {
      // Force-create or overwrite an identity record + reverse lookup + user link.
      const identity = {
        identityId: op.identityId,
        method: op.method,
        userId: op.userId || null,
        credentials: op.credentials || {},
        createdAt: op.timestamp,
        updatedAt: op.timestamp
      }

      await batch.put(`identity:${op.identityId}`, identity)

      // Recreate reverse lookup
      if (op.method === 'web-identity' || op.method === 'keet-identity') {
        const pk = (op.credentials && op.credentials.publicKey) || op.identityId
        await batch.put(`pubkey:${pk}`, { identityId: op.identityId })
      } else if (op.method === 'ssh-key' && op.credentials && op.credentials.fingerprint) {
        await batch.put(`sshkey:${op.credentials.fingerprint}`, { identityId: op.identityId })
      } else if (op.method === 'noise-key' && op.credentials && op.credentials.publicKey) {
        await batch.put(`noisekey:${op.credentials.publicKey}`, { identityId: op.identityId })
      }

      // Remove any revocation record
      await batch.del(`revoked:${op.identityId}`)

      // Add to user's identities array if userId provided
      if (op.userId) {
        const userEntry = await batch.get(`user:${op.userId}`)
        if (userEntry) {
          const user = userEntry.value
          if (!user.identities.includes(op.identityId)) {
            user.identities.push(op.identityId)
            user.updatedAt = op.timestamp
            await batch.put(`user:${op.userId}`, user)
          }
        }
      }
      break
    }

    case 'upgrade-permissions': {
      const userEntry = await batch.get(`user:${op.userId}`)
      if (!userEntry) break

      const user = userEntry.value
      let repo = user.repos.find(r => r.name === op.repoName)

      if (!repo) {
        repo = { name: op.repoName, permissions: [] }
        user.repos.push(repo)
      }

      // Set union of permissions
      repo.permissions = [...new Set([...repo.permissions, ...op.permissions])]
      user.updatedAt = op.timestamp
      await batch.put(`user:${op.userId}`, user)
      break
    }

    case 'merge-accounts': {
      const primaryEntry = await batch.get(`user:${op.primaryUserId}`)
      const secondaryEntry = await batch.get(`user:${op.secondaryUserId}`)
      if (!primaryEntry || !secondaryEntry) break

      const primary = primaryEntry.value
      const secondary = secondaryEntry.value

      // Skip if already merged
      if (secondary.mergedInto) break

      // Transfer identities from secondary to primary (skip revoked)
      for (const identityId of secondary.identities) {
        const idEntry = await batch.get(`identity:${identityId}`)
        if (!idEntry) continue
        const identity = idEntry.value
        if (identity.revoked) continue

        identity.userId = op.primaryUserId
        identity.updatedAt = op.timestamp
        await batch.put(`identity:${identityId}`, identity)

        if (!primary.identities.includes(identityId)) {
          primary.identities.push(identityId)
        }
      }

      // Merge repo permissions (Set union)
      for (const secondaryRepo of secondary.repos) {
        const existingRepo = primary.repos.find(r => r.name === secondaryRepo.name)
        if (existingRepo) {
          existingRepo.permissions = [...new Set([...existingRepo.permissions, ...secondaryRepo.permissions])]
        } else {
          primary.repos.push({ ...secondaryRepo })
        }
      }

      // Merge global permissions
      primary.globalPermissions = [...new Set([...primary.globalPermissions, ...secondary.globalPermissions])]

      primary.updatedAt = op.timestamp
      await batch.put(`user:${op.primaryUserId}`, primary)

      // Mark secondary as merged
      secondary.mergedInto = op.primaryUserId
      secondary.mergedAt = op.timestamp
      secondary.identities = []
      secondary.repos = []
      secondary.globalPermissions = []
      secondary.updatedAt = op.timestamp
      await batch.put(`user:${op.secondaryUserId}`, secondary)
      break
    }
  }
}