OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / facades / identity-facade.js

445 lines · javascript ref:
/**
 * IdentityFacade — IdentityRegistry-compatible facade over unified MetadataBase
 *
 * Implements the exact same public API as IdentityRegistry but delegates
 * all operations to MetadataBase. Zero changes needed in route files.
 *
 * Key prefixes: unchanged (user:, identity:, pubkey:, sshkey:, noisekey:, username:, revoked:)
 * These don't collide with any other namespace.
 */

import crypto from 'bare-crypto'

export class IdentityFacade {
  constructor ({ metadataBase }) {
    this._meta = metadataBase
  }

  // ── Properties (delegate to MetadataBase) ────────────────────

  get bootstrapKey () { return this._meta.key }
  get bootstrapKeyHex () { return this._meta.bootstrapKeyHex }
  get discoveryKey () { return this._meta.discoveryKey }
  get localKey () { return this._meta.localKey }
  get writable () { return this._meta.writable }
  get view () { return this._meta.view }
  get db () { return this._meta.view } // backward compat alias

  // Expose _base for wakeup protocol compatibility
  get _base () { return this._meta._base }

  async ready () {}

  // ── User Management (Write) ──────────────────────────────────

  async createUser (data = {}) {
    const userId = data.userId || crypto.randomBytes(16).toString('hex')
    await this._meta.append({
      v: 2,
      ns: 'identity',
      type: 'create-user',
      userId,
      username: data.username || null,
      name: data.name || data.username || 'Anonymous',
      email: data.email || null,
      profilePicture: data.profilePicture || null,
      globalPermissions: data.permissions || data.globalPermissions || ['read'],
      repos: data.repos || [{ name: '*', permissions: ['read'] }],
      timestamp: Date.now()
    })

    await this._meta.update()
    return this._meta.getByKey(`user:${userId}`)
  }

  async deactivateUser (userId, options = {}) {
    await this._meta.update()
    const user = await this._meta.getByKey(`user:${userId}`)
    if (!user) throw new Error(`User not found: ${userId}`)
    if (user.deactivatedAt) throw new Error(`User already deactivated: ${userId}`)

    await this._meta.append({
      v: 2,
      ns: 'identity',
      type: 'deactivate-user',
      userId,
      deactivatedBy: options.deactivatedBy || 'system',
      reason: options.reason || null,
      timestamp: Date.now()
    })

    await this._meta.update()
    return this._meta.getByKey(`user:${userId}`)
  }

  async reactivateUser (userId) {
    await this._meta.update()
    const user = await this._meta.getByKey(`user:${userId}`)
    if (!user) throw new Error(`User not found: ${userId}`)
    if (!user.deactivatedAt) throw new Error(`User not deactivated: ${userId}`)

    await this._meta.append({
      v: 2,
      ns: 'identity',
      type: 'reactivate-user',
      userId,
      timestamp: Date.now()
    })

    await this._meta.update()
    return this._meta.getByKey(`user:${userId}`)
  }

  async updateUserProfile (userId, updates) {
    await this._meta.update()
    const user = await this._meta.getByKey(`user:${userId}`)
    if (!user) throw new Error(`User not found: ${userId}`)

    // Separate arbitrary flags from known fields
    const knownFields = ['username', 'name', 'profilePicture', 'email', 'globalPermissions', 'repos']
    const flags = {}
    const op = {
      v: 2,
      ns: 'identity',
      type: 'update-user',
      userId,
      timestamp: Date.now()
    }

    for (const [key, value] of Object.entries(updates)) {
      if (knownFields.includes(key)) {
        op[key] = value
      } else {
        flags[key] = value
      }
    }

    if (Object.keys(flags).length > 0) {
      op.flags = flags
    }

    await this._meta.append(op)
    await this._meta.update()
    return this._meta.getByKey(`user:${userId}`)
  }

  async upgradePermissions (userId, repoName, newPermissions) {
    await this._meta.append({
      v: 2,
      ns: 'identity',
      type: 'upgrade-permissions',
      userId,
      repoName,
      permissions: newPermissions,
      timestamp: Date.now()
    })

    await this._meta.update()
    return this._meta.getByKey(`user:${userId}`)
  }

  async mergeAccounts (primaryUserId, secondaryUserId, options = {}) {
    await this._meta.append({
      v: 2,
      ns: 'identity',
      type: 'merge-accounts',
      primaryUserId,
      secondaryUserId,
      timestamp: Date.now()
    })

    await this._meta.update()
    const primary = await this._meta.getByKey(`user:${primaryUserId}`)
    const merged = await this._meta.getByKey(`user:${secondaryUserId}`)
    return { primary, merged, stats: { identitiesTransferred: 0, permissionsMerged: 0 } }
  }

  // ── Identity Management (Write) ──────────────────────────────

  async createIdentity (data) {
    await this._meta.append({
      v: 2,
      ns: 'identity',
      type: 'create-identity',
      identityId: data.identityId,
      method: data.method,
      userId: data.userId || null,
      credentials: data.credentials || {},
      timestamp: Date.now()
    })

    await this._meta.update()
    return this._meta.getByKey(`identity:${data.identityId}`)
  }

  async linkIdentity (identityId, userId) {
    await this._meta.append({
      v: 2,
      ns: 'identity',
      type: 'link-identity',
      identityId,
      userId,
      timestamp: Date.now()
    })

    await this._meta.update()
    const user = await this._meta.getByKey(`user:${userId}`)
    const identity = await this._meta.getByKey(`identity:${identityId}`)
    return { user, identity }
  }

  async unlinkIdentity (identityId) {
    await this._meta.update()
    const identity = await this._meta.getByKey(`identity:${identityId}`)
    if (!identity) throw new Error(`Identity not found: ${identityId}`)

    await this._meta.append({
      v: 2,
      ns: 'identity',
      type: 'unlink-identity',
      identityId,
      timestamp: Date.now()
    })

    await this._meta.update()
    if (identity.userId) {
      return this._meta.getByKey(`user:${identity.userId}`)
    }
    return null
  }

  async revokeIdentity (identityId, options = {}) {
    await this._meta.append({
      v: 2,
      ns: 'identity',
      type: 'revoke-identity',
      identityId,
      revokedBy: options.revokedBy || 'system',
      reason: options.reason || 'No reason provided',
      timestamp: Date.now()
    })

    await this._meta.update()
    return this._meta.getByKey(`identity:${identityId}`)
  }

  async restoreIdentity (identityId) {
    await this._meta.append({
      v: 2,
      ns: 'identity',
      type: 'restore-identity',
      identityId,
      timestamp: Date.now()
    })

    await this._meta.update()
    return this._meta.getByKey(`identity:${identityId}`)
  }

  async repairIdentity (data) {
    await this._meta.append({
      v: 2,
      ns: 'identity',
      type: 'repair-identity',
      identityId: data.identityId,
      method: data.method,
      userId: data.userId || null,
      credentials: data.credentials || {},
      timestamp: Date.now()
    })

    await this._meta.update()
    return this._meta.getByKey(`identity:${data.identityId}`)
  }

  async autoRegister (identityData) {
    const userId = crypto.randomBytes(16).toString('hex')
    const identityId = identityData.identityId

    // Create user
    await this._meta.append({
      v: 2,
      ns: 'identity',
      type: 'create-user',
      userId,
      username: identityData.username || null,
      name: identityData.name || identityData.username || 'Anonymous',
      email: null,
      profilePicture: null,
      globalPermissions: ['read'],
      repos: [{ name: '*', permissions: ['read'] }],
      timestamp: Date.now()
    })

    // Create identity linked to user
    await this._meta.append({
      v: 2,
      ns: 'identity',
      type: 'create-identity',
      identityId,
      method: identityData.method,
      userId,
      credentials: identityData.credentials || {},
      timestamp: Date.now()
    })

    await this._meta.update()
    const user = await this._meta.getByKey(`user:${userId}`)
    const identity = await this._meta.getByKey(`identity:${identityId}`)
    return { user, identity }
  }

  // ── Federation (Multi-Writer) ────────────────────────────────

  async addWriter (writerKey) {
    const keyHex = Buffer.isBuffer(writerKey) ? writerKey.toString('hex') : writerKey
    await this._meta.append({
      v: 2,
      ns: 'identity',
      type: 'add-writer',
      writerKey: keyHex,
      timestamp: Date.now()
    })
  }

  // ── Lookup/Query (Read) ──────────────────────────────────────

  async getUser (userId) {
    await this._meta.update()
    return this._meta.getByKey(`user:${userId}`)
  }

  async getIdentity (identityId) {
    await this._meta.update()
    return this._meta.getByKey(`identity:${identityId}`)
  }

  async getUserByIdentity (identityId) {
    await this._meta.update()
    const identity = await this._meta.getByKey(`identity:${identityId}`)
    if (!identity || !identity.userId) return null
    return this._meta.getByKey(`user:${identity.userId}`)
  }

  async getIdentityByPublicKey (publicKey) {
    await this._meta.update()
    const entry = await this._meta.getByKey(`pubkey:${publicKey}`)
    if (!entry) return null
    return this._meta.getByKey(`identity:${entry.identityId}`)
  }

  async getIdentityBySSHKey (sshPublicKey) {
    const fingerprint = this.calculateSSHFingerprint(sshPublicKey)
    await this._meta.update()
    const entry = await this._meta.getByKey(`sshkey:${fingerprint}`)
    if (!entry) return null
    return this._meta.getByKey(`identity:${entry.identityId}`)
  }

  async getIdentityByNoiseKey (noiseKeyHex) {
    await this._meta.update()
    const entry = await this._meta.getByKey(`noisekey:${noiseKeyHex}`)
    if (!entry) return null
    return this._meta.getByKey(`identity:${entry.identityId}`)
  }

  async listUserIdentities (userId) {
    await this._meta.update()
    const user = await this._meta.getByKey(`user:${userId}`)
    if (!user || !user.identities) return []

    const results = []
    for (const identityId of user.identities) {
      const identity = await this._meta.getByKey(`identity:${identityId}`)
      if (identity) results.push(identity)
    }
    return results
  }

  async isRevoked (identityId) {
    await this._meta.update()
    const revoked = await this._meta.getByKey(`revoked:${identityId}`)
    if (revoked) return true
    // Fallback: check identity record
    const identity = await this._meta.getByKey(`identity:${identityId}`)
    return identity ? !!identity.revoked : false
  }

  async checkUsernameAvailable (username) {
    if (!username) return false
    const normalized = username.toLowerCase().trim()
    await this._meta.update()
    const entry = await this._meta.getByKey(`username:${normalized}`)
    return !entry
  }

  async listRevokedIdentities () {
    return this._meta.list('revoked')
  }

  async listAllUsers () {
    return this._meta.list('user')
  }

  async listAllIdentities () {
    return this._meta.list('identity')
  }

  async getMergePreview (primaryUserId, secondaryUserId) {
    await this._meta.update()
    const primary = await this._meta.getByKey(`user:${primaryUserId}`)
    const secondary = await this._meta.getByKey(`user:${secondaryUserId}`)
    if (!primary || !secondary) return null

    const secondaryIdentities = []
    for (const identityId of (secondary.identities || [])) {
      const identity = await this._meta.getByKey(`identity:${identityId}`)
      if (identity && !identity.revoked) {
        secondaryIdentities.push({ identityId, method: identity.method })
      }
    }

    return {
      primary: {
        userId: primaryUserId,
        name: primary.name,
        identityCount: (primary.identities || []).length,
        repoCount: (primary.repos || []).length
      },
      secondary: {
        userId: secondaryUserId,
        name: secondary.name,
        identityCount: (secondary.identities || []).length,
        repoCount: (secondary.repos || []).length,
        identities: secondaryIdentities
      },
      willTransfer: {
        identities: secondaryIdentities.length,
        repoPermissions: (secondary.repos || []).length
      }
    }
  }

  // ── Utilities ────────────────────────────────────────────────

  calculateSSHFingerprint (sshPublicKey) {
    const parts = sshPublicKey.trim().split(/\s+/)
    const keyData = parts.length >= 2 ? parts[1] : parts[0]
    return crypto.createHash('sha256').update(Buffer.from(keyData, 'base64')).digest('hex').slice(0, 32)
  }

  // ── Lifecycle ────────────────────────────────────────────────

  async replicate (stream) {
    return stream
  }

  async sync (options = {}) {
    await this._meta.update()
  }

  async close () {
    // MetadataBase owns the Autobase lifecycle
  }
}