Skip to main content

Account Migration

Account migration allows users to move their identity and followers from one server to another. ActivityPub uses the Move activity along with movedTo and alsoKnownAs properties to facilitate this. This guide covers implementing migration support.

Migration Overview

┌────────────────────────────────────────────────────────┐
│ MIGRATION FLOW │
├────────────────────────────────────────────────────────┤
│ │
│ 1. User creates new account on new server │
│ ↓ │
│ 2. New account sets alsoKnownAs → old account │
│ ↓ │
│ 3. Old account sets movedTo → new account │
│ ↓ │
│ 4. Old server sends Move activity to followers │
│ ↓ │
│ 5. Followers' servers unfollow old, follow new │
│ │
└────────────────────────────────────────────────────────┘

Actor Properties

movedTo

Indicates where the account has moved to:

{
"type": "Person",
"id": "https://old.example/users/alice",
"movedTo": "https://new.example/users/alice"
}

alsoKnownAs

Lists aliases for the account (used to verify migration):

{
"type": "Person",
"id": "https://new.example/users/alice",
"alsoKnownAs": [
"https://old.example/users/alice"
]
}

The Move Activity

{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://old.example/activities/move-123",
"type": "Move",
"actor": "https://old.example/users/alice",
"object": "https://old.example/users/alice",
"target": "https://new.example/users/alice"
}
PropertyDescription
actorThe old account initiating the move
objectThe old account (same as actor)
targetThe new account to migrate to

Implementing Migration

Step 1: Setup New Account

On the new server, create the account with alsoKnownAs:

async function createMigrationTarget(username, oldAccountId) {
const actor = await createActor(username);

// Add the old account as an alias
await db.actors.update(
{ id: actor.id },
{
$set: {
alsoKnownAs: [oldAccountId]
}
}
);

return actor;
}

Step 2: Mark Old Account as Moved

On the old server:

async function initiateMove(oldActor, newAccountId) {
// Verify the new account accepts this migration
const newActor = await fetchActor(newAccountId);

if (!newActor.alsoKnownAs?.includes(oldActor.id)) {
throw new Error('New account must list old account in alsoKnownAs');
}

// Mark the old account as moved
await db.actors.update(
{ id: oldActor.id },
{
$set: {
movedTo: newAccountId
}
}
);

// Send Move activity to all followers
await sendMoveActivity(oldActor, newAccountId);

return { success: true, newAccount: newAccountId };
}

Step 3: Send Move Activity

async function sendMoveActivity(oldActor, newAccountId) {
const activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
id: `https://${config.domain}/activities/${uuid()}`,
type: 'Move',
actor: oldActor.id,
object: oldActor.id,
target: newAccountId
};

// Get all followers
const followers = await db.followers.find({ actor: oldActor.id });

// Deliver to each follower
for (const follower of followers) {
try {
const followerActor = await fetchActor(follower.follower);
await deliverActivity(activity, followerActor.inbox, oldActor);
} catch (error) {
console.log(`Failed to notify ${follower.follower}:`, error);
}
}
}

Processing Incoming Move

When receiving a Move activity:

async function processMove(activity, signer) {
// Verify actor matches signer
if (activity.actor !== signer.id) {
throw new Error('Actor does not match signer');
}

// Verify object matches actor (moving themselves)
if (activity.object !== activity.actor) {
throw new Error('Can only move your own account');
}

const oldAccountId = activity.actor;
const newAccountId = activity.target;

// Fetch and verify the new account
const newActor = await fetchActor(newAccountId);

// Verify alsoKnownAs links back to old account
if (!newActor.alsoKnownAs?.includes(oldAccountId)) {
throw new Error('Migration not verified: alsoKnownAs missing');
}

// Verify the old account has movedTo set
const oldActor = await fetchActor(oldAccountId);
if (oldActor.movedTo !== newAccountId) {
throw new Error('Migration not verified: movedTo mismatch');
}

// Process the migration for local followers
await migrateFollowers(oldAccountId, newAccountId);
}

async function migrateFollowers(oldAccountId, newAccountId) {
// Find local users following the old account
const localFollowers = await db.following.find({
following: oldAccountId
});

for (const record of localFollowers) {
const localActor = await db.actors.findOne({ id: record.actor });
if (!localActor) continue;

// Unfollow old account
await sendUnfollow(localActor, { id: oldAccountId });

// Follow new account
await sendFollow(localActor, { id: newAccountId });

console.log(`Migrated ${localActor.id} from ${oldAccountId} to ${newAccountId}`);
}
}

Migration Validation

Verify Before Processing

async function validateMigration(oldAccountId, newAccountId) {
const errors = [];

// Fetch both accounts
const [oldActor, newActor] = await Promise.all([
fetchActor(oldAccountId).catch(() => null),
fetchActor(newAccountId).catch(() => null)
]);

if (!oldActor) {
errors.push('Old account not found');
}
if (!newActor) {
errors.push('New account not found');
}

if (oldActor && newActor) {
// Check movedTo
if (oldActor.movedTo !== newAccountId) {
errors.push('Old account movedTo does not point to new account');
}

// Check alsoKnownAs
if (!newActor.alsoKnownAs?.includes(oldAccountId)) {
errors.push('New account alsoKnownAs does not include old account');
}
}

return {
valid: errors.length === 0,
errors
};
}

Displaying Moved Accounts

Show Migration Notice

function renderActor(actor) {
if (actor.movedTo) {
return `
<div class="actor moved">
<div class="migration-notice">
This account has moved to
<a href="${actor.movedTo}">${actor.movedTo}</a>
</div>
<p>You can no longer follow this account.</p>
</div>
`;
}

return `
<div class="actor">
<!-- Normal actor display -->
</div>
`;
}

Prevent Following Moved Accounts

async function sendFollow(follower, followee) {
// Check if target has moved
const targetActor = await fetchActor(followee.id);

if (targetActor.movedTo) {
throw new Error(
`This account has moved to ${targetActor.movedTo}. ` +
`Please follow the new account instead.`
);
}

// Normal follow process...
}

Limitations

What Doesn't Migrate

Account migration has significant limitations:

  • Posts don't migrate: Your old posts stay on the old server
  • Media doesn't migrate: Images, videos remain on old server
  • Likes/boosts don't migrate: Engagement history is lost
  • Blocks may not migrate: Block lists need manual recreation
  • If old server dies: Migration may fail if old server is down

Alternative: Mitra-Style Migration

Mitra sends the Move from the new server, allowing migration even if the old server is down:

// New server initiates the Move
async function initiateMoveFromNewServer(newActor, oldAccountId) {
// Verify alsoKnownAs is set
if (!newActor.alsoKnownAs?.includes(oldAccountId)) {
throw new Error('Must set alsoKnownAs first');
}

// Try to verify with old server
try {
const oldActor = await fetchActor(oldAccountId);
if (oldActor.movedTo !== newActor.id) {
throw new Error('Old account does not have movedTo set');
}
} catch (error) {
// Old server might be down - proceed with caution
console.warn('Could not verify with old server:', error);
}

// Send Move activity
const activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
id: `${newActor.id}/move`,
type: 'Move',
actor: oldAccountId,
object: oldAccountId,
target: newActor.id
};

// Deliver to known followers (requires follower list export)
// ...
}

Data Export/Import

Export User Data

async function exportUserData(actor) {
const [followers, following, posts, blocks] = await Promise.all([
db.followers.find({ actor: actor.id }),
db.following.find({ actor: actor.id }),
db.notes.find({ attributedTo: actor.id }),
db.blocks.find({ actor: actor.id })
]);

return {
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'OrderedCollection',
actor: actor,
followers: followers.map(f => f.follower),
following: following.map(f => f.following),
outbox: posts,
blocks: blocks.map(b => b.target),
exportedAt: new Date().toISOString()
};
}

Import Following List

async function importFollowing(actor, followingList) {
for (const targetId of followingList) {
try {
await sendFollow(actor, { id: targetId });
} catch (error) {
console.log(`Could not follow ${targetId}:`, error);
}
}
}

Common Issues

Race Conditions

Handle the case where Move arrives before alsoKnownAs is set:

async function processMove(activity, signer) {
// Retry fetching new account a few times
for (let i = 0; i < 3; i++) {
const newActor = await fetchActor(activity.target, { force: true });

if (newActor.alsoKnownAs?.includes(activity.actor)) {
// Valid, proceed
break;
}

if (i < 2) {
await sleep(5000); // Wait 5 seconds
} else {
throw new Error('Migration validation failed after retries');
}
}
}

Cooldown Period

Prevent abuse with migration cooldowns:

async function canMigrate(actor) {
const lastMigration = actor.lastMigratedAt;

if (lastMigration) {
const cooldown = 30 * 24 * 60 * 60 * 1000; // 30 days
if (Date.now() - lastMigration < cooldown) {
return {
allowed: false,
reason: 'Must wait 30 days between migrations'
};
}
}

return { allowed: true };
}

Next Steps