OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / lib / auth / identity-registry.js

719 lines · javascript ref:
import crypto from 'hypercore-crypto'

/**
 * Identity Registry
 *
 * Manages Users and Identities in Hyperbee.
 *
 * Key Concepts:
 * - User: A person with permissions (can have multiple identities)
 * - Identity: An authentication method (web keypair, keet-identity, token)
 * - Linking: Multiple identities can link to the same user
 *
 * Storage Schema:
 * - user:{userId} → User object
 * - identity:{identityId} → Identity object
 * - pubkey:{publicKey} → { identityId }  (reverse lookup)
 * - username:{username} → { userId }  (reverse lookup, enforces uniqueness)
 */

const RESERVED_USERNAMES = [
  'api', 'admin', 'web', 'sync', 'static', 'assets', 'auth',
  'health', 'status', 'login', 'logout', 'register', 'settings'
]

const USERNAME_REGEX = /^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$/
export class IdentityRegistry {
  constructor(hyperbee) {
    this.db = hyperbee
  }

  /**
   * Create a new User account
   * @param {Object} data - User data
   * @returns {Promise<Object>} Created user
   */
  async createUser(data = {}) {
    const userId = crypto.randomBytes(16).toString('hex')

    const user = {
      userId,
      username: data.username || null,
      name: data.name || 'Anonymous',
      email: data.email || null,
      profilePicture: data.profilePicture || null,
      globalPermissions: data.permissions || ['read'],
      notebooks: data.notebooks || [{ name: '*', permissions: ['read'] }],
      identities: [],
      createdAt: Date.now(),
      updatedAt: Date.now()
    }

    await this.db.put(`user:${userId}`, user)
    return user
  }

  /**
   * Create an Identity (auth method)
   * @param {Object} data - Identity data
   * @returns {Promise<Object>} Created identity
   */
  async createIdentity(data) {
    const identity = {
      identityId: data.identityId,
      method: data.method,
      userId: data.userId || null,
      credentials: data.credentials || {},
      createdAt: Date.now(),
      updatedAt: Date.now()
    }

    await this.db.put(`identity:${data.identityId}`, identity)

    // Create reverse lookups
    if (data.method === 'web-identity' || data.method === 'keet-identity') {
      const publicKey = data.credentials.publicKey || data.identityId
      await this.db.put(`pubkey:${publicKey}`, { identityId: data.identityId })
    }

    return identity
  }

  /**
   * Link an identity to a user
   * @param {string} identityId - Identity ID
   * @param {string} userId - User ID
   * @returns {Promise<Object>} { user, identity }
   */
  async linkIdentity(identityId, userId) {
    const identity = await this.getIdentity(identityId)
    const user = await this.getUser(userId)

    if (!identity || !user) {
      throw new Error('Identity or User not found')
    }

    // Update identity
    identity.userId = userId
    identity.updatedAt = Date.now()
    await this.db.put(`identity:${identityId}`, identity)

    // Update user's identity list
    if (!user.identities.includes(identityId)) {
      user.identities.push(identityId)
      user.updatedAt = Date.now()
      await this.db.put(`user:${userId}`, user)
    }

    return { user, identity }
  }

  /**
   * Auto-register: Create a new user account for a first-time identity.
   *
   * Every new identity gets its own separate user account.
   * First user gets admin permissions, all subsequent get read-only.
   * Admins can promote other users via POST /api/admin/promote.
   *
   * @param {Object} identityData - Identity data
   * @returns {Promise<Object>} { user, identity }
   */
  async autoRegister(identityData) {
    const hasUsers = await this._hasAnyUsers()

    // First user gets admin, subsequent get read-only
    const permissions = hasUsers ? ['read'] : ['read', 'write', 'admin']
    const notebooks = hasUsers
      ? [{ name: '*', permissions: ['read'] }]
      : [{ name: '*', permissions: ['read', 'write', 'admin'] }]

    const user = await this.createUser({
      name: identityData.name || 'Anonymous',
      permissions,
      notebooks
    })

    const identity = await this.createIdentity({
      identityId: identityData.identityId,
      method: identityData.method,
      userId: user.userId,
      credentials: identityData.credentials
    })

    user.identities.push(identity.identityId)
    await this.db.put(`user:${user.userId}`, user)

    return { user, identity }
  }

  /**
   * Find the first admin user
   * @returns {Promise<Object|null>} Admin user or null
   */
  async _findAdminUser() {
    for await (const entry of this.db.createReadStream({ gte: 'user:', lt: 'user:\xff' })) {
      const user = entry.value
      if (user.globalPermissions && user.globalPermissions.includes('admin')) {
        return user
      }
    }
    return null
  }

  /**
   * Get user by any linked identity
   * @param {string} identityId - Identity ID
   * @returns {Promise<Object|null>} User or null
   */
  async getUserByIdentity(identityId) {
    const identity = await this.getIdentity(identityId)
    if (!identity || !identity.userId) {
      return null
    }

    return this.getUser(identity.userId)
  }

  /**
   * Get identity by ID
   * @param {string} identityId - Identity ID
   * @returns {Promise<Object|null>} Identity or null
   */
  async getIdentity(identityId) {
    const entry = await this.db.get(`identity:${identityId}`)
    return entry?.value
  }

  /**
   * Get user by ID
   * @param {string} userId - User ID
   * @returns {Promise<Object|null>} User or null
   */
  async getUser(userId) {
    const entry = await this.db.get(`user:${userId}`)
    return entry?.value
  }

  /**
   * Check if any users exist in the registry
   * @returns {Promise<boolean>}
   */
  async _hasAnyUsers() {
    for await (const _ of this.db.createReadStream({ gte: 'user:', lt: 'user:\xff', limit: 1 })) {
      return true
    }
    return false
  }

  /**
   * Check if any admin user exists
   * @returns {Promise<boolean>}
   */
  async _hasAnyAdmin() {
    for await (const entry of this.db.createReadStream({ gte: 'user:', lt: 'user:\xff' })) {
      const user = entry.value
      if (user.globalPermissions && user.globalPermissions.includes('admin')) {
        return true
      }
    }
    return false
  }

  /**
   * Lookup identity by public key (web-identity or keet-identity)
   * @param {string} publicKey - Public key string
   * @returns {Promise<Object|null>} Identity or null
   */
  async getIdentityByPublicKey(publicKey) {
    const entry = await this.db.get(`pubkey:${publicKey}`)
    if (!entry) return null

    return this.getIdentity(entry.value.identityId)
  }

  /**
   * Upgrade user permissions
   * @param {string} userId - User ID
   * @param {string} notebookName - Notebook name (or '*' for global)
   * @param {Array<string>} newPermissions - Permissions to add
   * @returns {Promise<Object>} Updated user
   */
  async upgradePermissions(userId, notebookName, newPermissions) {
    const user = await this.getUser(userId)
    if (!user) {
      throw new Error('User not found')
    }

    // Find or create notebook-specific permissions
    let notebook = user.notebooks.find(r => r.name === notebookName)

    if (!notebook) {
      notebook = { name: notebookName, permissions: [] }
      user.notebooks.push(notebook)
    }

    // Merge permissions (add new, keep existing)
    notebook.permissions = [...new Set([...notebook.permissions, ...newPermissions])]

    // Also update globalPermissions when upgrading wildcard scope
    if (notebookName === '*') {
      user.globalPermissions = [...new Set([...user.globalPermissions, ...newPermissions])]
    }

    user.updatedAt = Date.now()

    await this.db.put(`user:${userId}`, user)
    return user
  }

  /**
   * List all identities for a user
   * @param {string} userId - User ID
   * @returns {Promise<Array>} Array of identities
   */
  async listUserIdentities(userId) {
    const user = await this.getUser(userId)
    if (!user) return []

    const identities = []
    for (const identityId of user.identities) {
      const identity = await this.getIdentity(identityId)
      if (identity) {
        identities.push(identity)
      }
    }

    return identities
  }

  /**
   * Unlink an identity from a user
   * @param {string} identityId - Identity ID
   * @returns {Promise<Object>} Updated user
   */
  async unlinkIdentity(identityId) {
    const identity = await this.getIdentity(identityId)
    if (!identity || !identity.userId) {
      throw new Error('Identity not found or not linked')
    }

    const user = await this.getUser(identity.userId)

    // Remove from user's identity list
    user.identities = user.identities.filter(id => id !== identityId)
    user.updatedAt = Date.now()
    await this.db.put(`user:${user.userId}`, user)

    // Clear userId from identity
    identity.userId = null
    identity.updatedAt = Date.now()
    await this.db.put(`identity:${identityId}`, identity)

    return user
  }

  /**
   * Revoke an identity (mark as revoked, preventing authentication)
   * @param {string} identityId - Identity ID
   * @param {Object} options - { reason, revokedBy }
   * @returns {Promise<Object>} Revoked identity
   */
  async revokeIdentity(identityId, options = {}) {
    const identity = await this.getIdentity(identityId)
    if (!identity) {
      throw new Error('Identity not found')
    }

    if (identity.revoked) {
      throw new Error('Identity already revoked')
    }

    // Mark as revoked
    identity.revoked = true
    identity.revokedAt = Date.now()
    identity.revokedBy = options.revokedBy || 'system'
    identity.revocationReason = options.reason || 'No reason provided'
    identity.updatedAt = Date.now()

    await this.db.put(`identity:${identityId}`, identity)

    // Add to revocation list for quick checks
    await this.db.put(`revoked:${identityId}`, {
      identityId,
      revokedAt: identity.revokedAt,
      revokedBy: identity.revokedBy,
      reason: identity.revocationReason
    })

    console.log(`[Identity] Revoked ${identity.method}: ${identityId.substring(0, 8)}... (${options.reason || 'no reason'})`)

    return identity
  }

  /**
   * Restore a revoked identity
   * @param {string} identityId - Identity ID
   * @returns {Promise<Object>} Restored identity
   */
  async restoreIdentity(identityId) {
    const identity = await this.getIdentity(identityId)
    if (!identity) {
      throw new Error('Identity not found')
    }

    if (!identity.revoked) {
      throw new Error('Identity not revoked')
    }

    // Remove revocation
    delete identity.revoked
    delete identity.revokedAt
    delete identity.revokedBy
    delete identity.revocationReason
    identity.restoredAt = Date.now()
    identity.updatedAt = Date.now()

    await this.db.put(`identity:${identityId}`, identity)

    // Remove from revocation list
    await this.db.del(`revoked:${identityId}`)

    console.log(`[Identity] Restored ${identity.method}: ${identityId.substring(0, 8)}...`)

    return identity
  }

  /**
   * Check if identity is revoked
   * @param {string} identityId - Identity ID
   * @returns {Promise<boolean>} True if revoked
   */
  async isRevoked(identityId) {
    // Quick check in revocation list
    const revoked = await this.db.get(`revoked:${identityId}`)
    if (revoked) {
      return true
    }

    // Fallback: Check identity object
    const identity = await this.getIdentity(identityId)
    return identity?.revoked === true
  }

  /**
   * List all revoked identities
   * @returns {Promise<Array>} Array of revoked identities
   */
  async listRevokedIdentities() {
    const revoked = []

    for await (const entry of this.db.createReadStream({ gte: 'revoked:', lt: 'revoked:\xff' })) {
      revoked.push(entry.value)
    }

    return revoked
  }

  /**
   * Validate username format
   * @param {string} username - Username to validate
   * @returns {{ valid: boolean, reason?: string }}
   */
  validateUsername(username) {
    if (!username || typeof username !== 'string') {
      return { valid: false, reason: 'Username is required' }
    }

    const lower = username.toLowerCase()
    if (lower !== username) {
      return { valid: false, reason: 'Username must be lowercase' }
    }

    if (username.length < 3 || username.length > 32) {
      return { valid: false, reason: 'Username must be 3-32 characters' }
    }

    if (!USERNAME_REGEX.test(username)) {
      return { valid: false, reason: 'Username must be lowercase alphanumeric with dashes, no leading/trailing dash or consecutive dashes' }
    }

    if (username.includes('--')) {
      return { valid: false, reason: 'Username must not contain consecutive dashes' }
    }

    if (RESERVED_USERNAMES.includes(username)) {
      return { valid: false, reason: 'Username is reserved' }
    }

    return { valid: true }
  }

  /**
   * Set username for a user (unique, validated)
   * @param {string} userId - User ID
   * @param {string} username - Desired username
   * @returns {Promise<Object>} Updated user
   * @throws {Error} If username is invalid or taken
   */
  async setUsername(userId, username) {
    const validation = this.validateUsername(username)
    if (!validation.valid) {
      const err = new Error(validation.reason)
      err.code = 'VALIDATION_ERROR'
      err.status = 400
      throw err
    }

    const user = await this.getUser(userId)
    if (!user) throw new Error('User not found')

    // Check uniqueness
    const existing = await this.db.get(`username:${username}`)
    if (existing && existing.value.userId !== userId) {
      const err = new Error('Username already taken')
      err.code = 'CONFLICT'
      err.status = 409
      throw err
    }

    // Remove old username reverse lookup if changing
    if (user.username && user.username !== username) {
      await this.db.del(`username:${user.username}`)
    }

    // Set new username
    user.username = username
    user.updatedAt = Date.now()
    await this.db.put(`user:${userId}`, user)
    await this.db.put(`username:${username}`, { userId })

    return user
  }

  /**
   * Get user by username
   * @param {string} username - Username
   * @returns {Promise<Object|null>} User or null
   */
  async getUserByUsername(username) {
    const entry = await this.db.get(`username:${username}`)
    if (!entry) return null
    return this.getUser(entry.value.userId)
  }

  /**
   * Check if a username is available
   * @param {string} username - Username to check
   * @returns {Promise<boolean>} True if available
   */
  async isUsernameAvailable(username) {
    const validation = this.validateUsername(username)
    if (!validation.valid) return false

    const entry = await this.db.get(`username:${username}`)
    return !entry
  }

  /**
   * Update user profile (name, picture)
   * @param {string} userId - User ID
   * @param {Object} updates - Fields to update
   * @returns {Promise<Object>} Updated user
   */
  async updateUserProfile(userId, updates) {
    const user = await this.getUser(userId)
    if (!user) throw new Error('User not found')

    if (updates.name !== undefined) {
      user.name = updates.name
    }

    if (updates.theme !== undefined) {
      user.theme = updates.theme
    }

    if (updates.profilePicture !== undefined) {
      user.profilePicture = updates.profilePicture
    }

    user.updatedAt = Date.now()

    await this.db.put(`user:${userId}`, user)
    return user
  }

  /**
   * List all users (admin only)
   * @returns {Promise<Array>} Array of all users
   */
  async listAllUsers() {
    const users = []

    for await (const entry of this.db.createReadStream({ gte: 'user:', lt: 'user:\xff' })) {
      users.push(entry.value)
    }

    return users
  }

  /**
   * List all identities (admin only)
   * @returns {Promise<Array>} Array of all identities
   */
  async listAllIdentities() {
    const identities = []

    for await (const entry of this.db.createReadStream({ gte: 'identity:', lt: 'identity:\xff' })) {
      identities.push(entry.value)
    }

    return identities
  }

  /**
   * Soft-delete a user. Marks the user deactivated and revokes every linked
   * identity so it can no longer authenticate (providers reject revoked
   * identities). NEVER hard-deletes — the user, identity, and username records
   * are all preserved for audit and reactivation. (Hard `db.del` of identity
   * rows previously destroyed 28 accounts; identity cleanup must be soft.)
   * @param {string} userId
   * @param {Object} options - { by, reason }
   * @returns {Promise<Object>} The deactivated user
   */
  async deactivateUser(userId, options = {}) {
    const user = await this.getUser(userId)
    if (!user) throw new Error('User not found')
    if (user.deactivated) return user

    // Revoke every linked identity — this is what actually blocks auth.
    for (const identityId of (user.identities || [])) {
      try {
        if (!(await this.isRevoked(identityId))) {
          await this.revokeIdentity(identityId, {
            reason: options.reason || 'user deactivated',
            revokedBy: options.by || 'admin'
          })
        }
      } catch (err) {
        console.error(`[Identity] deactivateUser: failed to revoke ${identityId}:`, err.message)
      }
    }

    user.deactivated = true
    user.deactivatedAt = Date.now()
    user.deactivatedBy = options.by || 'admin'
    user.deactivationReason = options.reason || null
    user.updatedAt = Date.now()
    await this.db.put(`user:${userId}`, user)

    console.log(`[Identity] Deactivated user ${userId} (by ${user.deactivatedBy})`)
    return user
  }

  /**
   * Reverse deactivateUser: clear the flag and restore the user's identities.
   * @param {string} userId
   * @returns {Promise<Object>} The reactivated user
   */
  async reactivateUser(userId) {
    const user = await this.getUser(userId)
    if (!user) throw new Error('User not found')
    if (!user.deactivated) return user

    for (const identityId of (user.identities || [])) {
      try {
        if (await this.isRevoked(identityId)) await this.restoreIdentity(identityId)
      } catch (err) {
        console.error(`[Identity] reactivateUser: failed to restore ${identityId}:`, err.message)
      }
    }

    delete user.deactivated
    delete user.deactivatedAt
    delete user.deactivatedBy
    delete user.deactivationReason
    user.reactivatedAt = Date.now()
    user.updatedAt = Date.now()
    await this.db.put(`user:${userId}`, user)

    console.log(`[Identity] Reactivated user ${userId}`)
    return user
  }

  /**
   * Merge two user accounts
   * Transfers all identities, permissions, and data from secondary to primary
   * @param {string} primaryUserId - The account to keep
   * @param {string} secondaryUserId - The account to merge in (will be marked as merged)
   * @returns {Promise<Object>} { primary, merged, stats }
   */
  async mergeAccounts(primaryUserId, secondaryUserId) {
    const primary = await this.getUser(primaryUserId)
    const secondary = await this.getUser(secondaryUserId)

    if (!primary) throw new Error('Primary user not found')
    if (!secondary) throw new Error('Secondary user not found')
    if (primaryUserId === secondaryUserId) throw new Error('Cannot merge account with itself')

    const stats = {
      identitiesTransferred: 0,
      permissionsMerged: 0
    }

    // 1. Transfer all identities from secondary to primary
    for (const identityId of secondary.identities) {
      const identity = await this.getIdentity(identityId)
      if (identity) {
        if (identity.revoked) {
          console.log(`[Identity] Skipping revoked identity during merge: ${identityId.substring(0, 8)}...`)
          continue
        }

        identity.userId = primaryUserId
        identity.updatedAt = Date.now()
        await this.db.put(`identity:${identityId}`, identity)

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

    // 2. Merge permissions (union of both)
    for (const secondaryNotebook of secondary.notebooks) {
      const existingNotebook = primary.notebooks.find(r => r.name === secondaryNotebook.name)
      if (existingNotebook) {
        existingNotebook.permissions = [...new Set([...existingNotebook.permissions, ...secondaryNotebook.permissions])]
      } else {
        primary.notebooks.push({ ...secondaryNotebook })
      }
      stats.permissionsMerged++
    }

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

    // 3. Update primary user
    primary.updatedAt = Date.now()
    await this.db.put(`user:${primaryUserId}`, primary)

    // 4. Mark secondary as merged (keep for audit trail)
    secondary.mergedInto = primaryUserId
    secondary.mergedAt = Date.now()
    secondary.identities = []
    secondary.notebooks = []
    secondary.globalPermissions = []
    secondary.updatedAt = Date.now()
    await this.db.put(`user:${secondaryUserId}`, secondary)

    console.log(`[Identity] Merged user ${secondaryUserId} into ${primaryUserId}:`, stats)

    return {
      primary,
      merged: secondary,
      stats
    }
  }
}