OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / routes / account-merge.js

271 lines · javascript ref:
/**
 * Account Merge Routes — multi-device identity linking via challenge-response
 */

import { json, html, unauthorized, badRequest, notFound, readJson } from '../response.js'
import { requireAuth } from '../middleware/require-auth.js'
import { ANProto } from '../anproto.js'
import { renderMergePage, renderMergeError, renderMergeComplete } from '../account-merge-ui.js'
import crypto from 'hypercore-crypto'

export function registerAccountMergeRoutes(router) {
  router.post('/api/user/merge/initiate', requireAuth, handleInitiateMerge)
  router.get('/api/user/merge/:mergeId', handleGetMergeRequest)
  router.post('/api/user/merge/:mergeId/confirm', handleConfirmMerge)
  router.get('/merge/:mergeId', handleMergePage)
}

async function handleInitiateMerge(req, res, server) {
  const { authManager } = server.auth
  const { pathPrefix } = server.config
  const { linkedRepos } = server.repoData
  try {
    const { targetPublicKey } = await readJson(req)

    if (!targetPublicKey) {
      badRequest(res, 'targetPublicKey is required - the public key of the account to merge')
      return
    }

    // Get current user (primary)
    const primaryResult = await authManager.getUserWithIdentities(req.auth.identityId)
    if (!primaryResult) {
      notFound(res, 'User not found')
      return
    }

    // Check target identity exists
    const targetIdentity = await authManager.identityRegistry.getIdentityByPublicKey(targetPublicKey)
    if (!targetIdentity) {
      notFound(res, 'Target identity not found. The account you want to merge must exist.')
      return
    }

    // Check not merging with self
    if (targetIdentity.userId === primaryResult.user.userId) {
      badRequest(res, 'Cannot merge account with itself')
      return
    }

    // Get primary's public key
    const primaryIdentities = primaryResult.identities.filter(i => i.method === 'web-identity')
    if (primaryIdentities.length === 0) {
      badRequest(res, 'Primary account must have a web-identity (ANProto)')
      return
    }
    const primaryPublicKey = primaryIdentities[0].publicKey || primaryIdentities[0].credentials?.publicKey || primaryIdentities[0].identityId

    // Generate merge request
    const mergeId = crypto.randomBytes(16).toString('hex')
    const challenge = crypto.randomBytes(32).toString('base64')
    const expiresAt = Date.now() + (5 * 60 * 1000) // 5 minutes

    const mergeRequest = {
      mergeId,
      primaryUserId: primaryResult.user.userId,
      primaryPublicKey,
      secondaryPublicKey: targetPublicKey,
      secondaryUserId: targetIdentity.userId,
      challenge,
      expiresAt,
      primarySig: null,
      secondarySig: null,
      status: 'pending',
      createdAt: Date.now()
    }

    // Store merge request
    await authManager.challengeManager.db.put(`merge:${mergeId}`, mergeRequest)

    console.log(`[Merge] Initiated: ${primaryPublicKey.substring(0, 8)}... wants to merge ${targetPublicKey.substring(0, 8)}...`)

    json(res, {
      mergeId,
      challenge,
      expiresAt,
      mergeUrl: `${pathPrefix}/merge/${mergeId}`,
      message: 'Share the merge URL with your other account to complete the merge'
    })
  } catch (err) {
    console.error('[API] Initiate merge error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Get merge request details
 */
async function handleGetMergeRequest(req, res, server) {
  const { authManager } = server.auth
  try {
    const { mergeId } = req.params

    const entry = await authManager.challengeManager.db.get(`merge:${mergeId}`)
    if (!entry) {
      notFound(res, 'Merge request not found')
      return
    }

    const mergeRequest = entry.value

    // Check expiration
    if (Date.now() > mergeRequest.expiresAt) {
      json(res, { error: 'Merge request expired', expired: true }, 410)
      return
    }

    // Don't expose full details to unauthenticated requests
    json(res, {
      mergeId: mergeRequest.mergeId,
      primaryPublicKey: mergeRequest.primaryPublicKey,
      secondaryPublicKey: mergeRequest.secondaryPublicKey,
      challenge: mergeRequest.challenge,
      expiresAt: mergeRequest.expiresAt,
      status: mergeRequest.status
    })
  } catch (err) {
    console.error('[API] Get merge request error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Confirm merge from secondary account
 * Both primary and secondary must sign the challenge
 */
async function handleConfirmMerge(req, res, server) {
  const { authManager } = server.auth
  const { linkedRepos } = server.repoData
  try {
    const { mergeId } = req.params
    const { signature, publicKey } = await readJson(req)

    if (!signature || !publicKey) {
      badRequest(res, 'signature and publicKey are required')
      return
    }

    // Get merge request
    const entry = await authManager.challengeManager.db.get(`merge:${mergeId}`)
    if (!entry) {
      notFound(res, 'Merge request not found')
      return
    }

    const mergeRequest = entry.value

    // Check expiration
    if (Date.now() > mergeRequest.expiresAt) {
      json(res, { error: 'Merge request expired', expired: true }, 410)
      return
    }

    // Check status
    if (mergeRequest.status === 'completed') {
      json(res, { error: 'Merge already completed' }, 400)
      return
    }

    // Verify signature
    const isValid = ANProto.verify(mergeRequest.challenge, signature, publicKey)
    if (!isValid) {
      forbidden(res, 'Invalid signature')
      return
    }

    // Determine which account is signing
    const isPrimary = publicKey === mergeRequest.primaryPublicKey
    const isSecondary = publicKey === mergeRequest.secondaryPublicKey

    if (!isPrimary && !isSecondary) {
      forbidden(res, 'Public key does not match either account in this merge request')
      return
    }

    // Store signature
    if (isPrimary) {
      mergeRequest.primarySig = signature
      console.log(`[Merge] Primary signed: ${publicKey.substring(0, 8)}...`)
    } else {
      mergeRequest.secondarySig = signature
      console.log(`[Merge] Secondary signed: ${publicKey.substring(0, 8)}...`)
    }

    // Check if both have signed
    if (mergeRequest.primarySig && mergeRequest.secondarySig) {
      // Execute merge!
      console.log(`[Merge] Both accounts signed, executing merge...`)

      const mergeResult = await authManager.identityRegistry.mergeAccounts(
        mergeRequest.primaryUserId,
        mergeRequest.secondaryUserId,
        { linkedRepos: linkedRepos }
      )

      mergeRequest.status = 'completed'
      mergeRequest.completedAt = Date.now()
      mergeRequest.stats = mergeResult.stats

      await authManager.challengeManager.db.put(`merge:${mergeId}`, mergeRequest)

      console.log(`[Merge] Complete! Stats:`, mergeResult.stats)

      json(res, {
        success: true,
        message: 'Accounts merged successfully!',
        stats: mergeResult.stats,
        primaryUserId: mergeRequest.primaryUserId
      })
    } else {
      // Still waiting for other signature
      await authManager.challengeManager.db.put(`merge:${mergeId}`, mergeRequest)

      const waiting = isPrimary ? 'secondary' : 'primary'
      json(res, {
        success: true,
        message: `Signature recorded. Waiting for ${waiting} account to sign.`,
        status: 'pending',
        primarySigned: !!mergeRequest.primarySig,
        secondarySigned: !!mergeRequest.secondarySig
      })
    }
  } catch (err) {
    console.error('[API] Confirm merge error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Serve merge confirmation page
 */
async function handleMergePage(req, res, server) {
  const { authManager } = server.auth
  const { pathPrefix } = server.config
  try {
    const { mergeId } = req.params

    const entry = await authManager.challengeManager.db.get(`merge:${mergeId}`)
    if (!entry) {
      html(res, renderMergeError('Merge request not found', pathPrefix))
      return
    }

    const mergeRequest = entry.value

    if (Date.now() > mergeRequest.expiresAt) {
      html(res, renderMergeError('This merge request has expired. Please create a new one.', pathPrefix))
      return
    }

    if (mergeRequest.status === 'completed') {
      html(res, renderMergeComplete(mergeRequest, pathPrefix))
      return
    }

    html(res, renderMergePage(mergeRequest, pathPrefix))
  } catch (err) {
    console.error('[API] Merge page error:', err)
    html(res, renderMergeError('Error loading merge request', pathPrefix))
  }
}