/**
* 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))
}
}