Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision 2dd1dc40db2f7dab2c095eeb309e5aaa02369b4b)
+++ src/config/database.js	(revision ccaa5301c212451f2e6b7c28c49282055278e961)
@@ -143,4 +143,8 @@
   ensureColumn('sites', 'external_playback', 'INTEGER');
   ensureColumn('sites', 'og_theme', 'TEXT');             // OG share-card variant: NULL=auto (follow site theme) | 'light' | 'dark'
+  // FEP-7628: former identities this actor claims (JSON array of actor URIs).
+  // Publishing them as alsoKnownAs is what lets the OLD server approve a Move
+  // of its followers to this account — the claim must be visible on OUR side.
+  ensureColumn('sites', 'ap_aliases', 'TEXT');
 
   // Per-post noindex + type
Index: src/routes/admin-sites.js
===================================================================
--- src/routes/admin-sites.js	(revision 2dd1dc40db2f7dab2c095eeb309e5aaa02369b4b)
+++ src/routes/admin-sites.js	(revision ccaa5301c212451f2e6b7c28c49282055278e961)
@@ -26,4 +26,5 @@
 import { toWebp } from '../services/ImageWebpService.js';
 import { mediaDir } from '../config/paths.js';
+import AP from '../services/ActivityPubService.js';
 
 
@@ -71,4 +72,25 @@
   }
   return arr.length ? JSON.stringify(arr) : null;
+}
+
+/**
+ * FEP-7628 aliases (alsoKnownAs): one former identity per line, as an actor
+ * URL or an @user@host handle. Handles resolve via WebFinger AT SAVE TIME on
+ * purpose — a typo'd alias that silently lands on the actor would make a later
+ * Move fail at the old server with no hint why. Throws the offending line.
+ */
+async function parseApAliases(raw, ownActorUri) {
+  const lines = String(raw || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
+  if (lines.length > 5) throw new Error(lines[5] + ' (max 5)');
+  const out = [];
+  for (const line of lines) {
+    let uri = null;
+    if (/^https?:\/\//i.test(line)) uri = line;
+    else if (line.includes('@')) uri = await AP.webfingerResolve(line).catch(() => null);
+    if (!uri) throw new Error(line);
+    if (uri === ownActorUri) continue; // claiming yourself adds nothing
+    if (!out.includes(uri)) out.push(uri);
+  }
+  return out;
 }
 
@@ -256,4 +278,7 @@
   }
 
+  let apAliases = '';
+  try { apAliases = (JSON.parse(site.ap_aliases || '[]') || []).join('\n'); } catch { /* show empty on malformed */ }
+
   renderPage(req, res, 'pages/admin-site-edit', {
     pageTitleKey: 'admin.t_editsite', pageTitleVars: { title: site.title },
@@ -266,4 +291,5 @@
     platforms: listPlatforms(),
     parsedLinks,
+    apAliases,
     success: req.query.success || null,
     error: req.query.error || null,
@@ -272,6 +298,6 @@
 
 // ==================== SAVE ====================
-router.post('/:slug/save', requireSiteManagerBySlug, (req, res) => {
-  const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(req.params.slug);
+router.post('/:slug/save', requireSiteManagerBySlug, async (req, res) => {
+  const site = db.prepare('SELECT id, ap_aliases FROM sites WHERE slug = ?').get(req.params.slug);
   if (!site) return res.redirect('/admin/sites?error=Not+found');
 
@@ -279,4 +305,14 @@
   const feedViewDef = f.feed_view_default === 'grid' ? 'grid' : 'timeline';
   const profileLinksJson = buildProfileLinks(f);
+
+  // FEP-7628 aliases — validated/resolved before anything is written.
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  let apAliasesJson = null;
+  try {
+    const arr = await parseApAliases(f.ap_aliases, AP.actorId(base, req.params.slug));
+    apAliasesJson = arr.length ? JSON.stringify(arr) : null;
+  } catch (e) {
+    return res.redirect(`/admin/sites/${req.params.slug}/edit?error=` + encodeURIComponent(`Alias niet herkend of niet vindbaar: ${e.message}`));
+  }
 
   // theme_override: only accept the three legal values. Empty string means
@@ -294,4 +330,5 @@
       profile_enabled = ?,
       profile_links = ?,
+      ap_aliases = ?,
       is_public = ?, robots_index = ?, require_login_to_comment = ?,
       enable_audio_player = ?,
@@ -312,4 +349,5 @@
     f.profile_enabled ? 1 : 0,
     profileLinksJson,
+    apAliasesJson,
     f.is_public ? 1 : 0,
     f.robots_index ? 1 : 0,
@@ -336,4 +374,14 @@
   }
 
+  // Alias change → broadcast an actor Update so remote caches refresh. The old
+  // server re-fetches the actor live during a Move anyway; this is freshness,
+  // not correctness, hence best-effort.
+  if ((site.ap_aliases || null) !== apAliasesJson) {
+    try {
+      const fresh = db.prepare('SELECT * FROM sites WHERE id = ?').get(site.id);
+      AP.deliverActorUpdate(fresh).catch(() => {});
+    } catch { /* never blocks the save */ }
+  }
+
   res.redirect(`/admin/sites/${req.params.slug}/edit?success=` + encodeURIComponent('Opgeslagen'));
 });
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 2dd1dc40db2f7dab2c095eeb309e5aaa02369b4b)
+++ src/services/ActivityPubService.js	(revision ccaa5301c212451f2e6b7c28c49282055278e961)
@@ -46,4 +46,7 @@
     manuallyApprovesFollowers: 'as:manuallyApprovesFollowers',
     discoverable: 'toot:discoverable',
+    // FEP-7628 (account moves): same term declaration Mastodon ships.
+    alsoKnownAs: { '@id': 'as:alsoKnownAs', '@type': '@id' },
+    movedTo: { '@id': 'as:movedTo', '@type': '@id' },
     featured: { '@id': 'toot:featured', '@type': '@id' },
     PropertyValue: 'schema:PropertyValue',
@@ -221,4 +224,14 @@
   // Account creation date — shown by Mastodon + read by indexers (additive, standard AS2).
   if (site.created_at) { try { actor.published = new Date(site.created_at).toISOString(); } catch { /* skip bad date */ } }
+  // FEP-7628: former identities this account claims. The OLD server checks for
+  // exactly this back-reference before it will move followers here, so the
+  // list must be on the public actor, not tucked away in settings.
+  try {
+    const aka = JSON.parse(site.ap_aliases || '[]');
+    if (Array.isArray(aka)) {
+      const clean = aka.filter((u) => typeof u === 'string' && /^https?:\/\//i.test(u) && u !== id);
+      if (clean.length) actor.alsoKnownAs = clean;
+    }
+  } catch { /* skip malformed ap_aliases */ }
   // Profile links → PropertyValue rows: Mastodon/PeerTube/WordPress-ActivityPub render these as
   // profile metadata (rel=me enables link-back verification). Additive; ignored by simpler receivers.
@@ -1437,5 +1450,5 @@
   // Blocked actor/domain → silently drop (202, don't reveal the block).
   if (claimedActor && isBlockedAny(claimedActor)) { console.log('[AP] inbox dropped (blocked)', claimedActor, 'from', ip); return 202; }
-  const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer'];
+  const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer', 'Move'];
   if (GATED.includes(type)) {
     if (!verified || !claimedActor || verified.id !== claimedActor) {
@@ -1513,4 +1526,11 @@
     } catch { /* ignore */ }
     return 202;
+  }
+
+  // FEP-7628 (DRAFT): an account moved house. Handled before Follow on purpose:
+  // a Move often arrives seconds before the new actor's re-Follow wave, and the
+  // swap below must not race our own outgoing Follow of the target.
+  if (type === 'Move') {
+    return handleMoveInbox(act, { verifiedActor: claimedActor });
   }
 
@@ -3760,4 +3780,67 @@
 }
 
+/**
+ * FEP-7628 (DRAFT status — the shape is Mastodon's since 2019, but the FEP can
+ * still change): an account our sites follow says it moved to a new home.
+ *
+ * Validity has two independent legs, and both must hold:
+ *  1. The SIGNER is a party to the move: the old actor announcing its own move
+ *     (push mode) or the new actor doing it (pull mode). A third party
+ *     narrating someone else's move is refused — without this, any signed
+ *     stranger could re-point our follows.
+ *  2. The NEW actor claims the old identity in its `alsoKnownAs`. That is the
+ *     cross-side proof: the mover controls both ends. Without it, whoever
+ *     holds ONE end could hijack the other end's followers.
+ *
+ * Effect: every local site following the old actor unfollows it and follows
+ * the new one, keeping its auto-boost choice. Deliberately NOT retargeted:
+ * guardianship relations (FEP-633c) — a guardian is a security anchor, not a
+ * feed subscription, and moving one is shaer-tge's gated decision, not a
+ * side effect of an inbox event. We only log when a move touches one.
+ *
+ * Deps are injectable for tests (no network in node:test).
+ */
+export async function handleMoveInbox(act, { verifiedActor = null, fetchActorFn = null, followFn = null, unfollowFn = null } = {}) {
+  const oldUri = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
+  const newUri = typeof act.target === 'string' ? act.target : (act.target && act.target.id);
+  if (!oldUri || !newUri || oldUri === newUri) return 400;
+  if (!verifiedActor || (verifiedActor !== oldUri && verifiedActor !== newUri)) {
+    console.warn('[AP] Move refused: signer is not a party to the move', verifiedActor || '(unsigned)', oldUri, '→', newUri);
+    return 401;
+  }
+  // Nobody here follows the old actor → nothing to move. This also makes
+  // redelivery idempotent: after the first swap the rows are gone.
+  let rows = [];
+  try { rows = db.prepare('SELECT * FROM ap_following WHERE actor_uri = ?').all(oldUri); } catch { /* fresh init */ }
+  if (!rows.length) return 202;
+  // A blocked destination is declined outright: the old follow stays (it goes
+  // stale on its own), and we will not open a door to a blocked house.
+  if (isBlockedAny(newUri)) { console.log('[AP] Move dropped: target is blocked', newUri); return 202; }
+  const target = await (fetchActorFn || fetchActor)(newUri);
+  const aka = [].concat((target && target.alsoKnownAs) || [])
+    .map((a) => (typeof a === 'string' ? a : (a && a.id))).filter(Boolean);
+  if (!target || !target.id || !aka.includes(oldUri)) {
+    console.warn('[AP] Move refused: target does not claim the old actor in alsoKnownAs', oldUri, '→', newUri);
+    return 202; // decline to act; no 4xx, the sender may be a well-meaning retrying server
+  }
+  for (const row of rows) {
+    const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(row.slug);
+    if (!site) continue;
+    try {
+      await (unfollowFn || unfollowActor)(site, oldUri);
+      const already = fwStmts().one.get(row.slug, newUri);
+      if (!already) await (followFn || followActor)(site, newUri, !!row.auto_boost);
+      console.log('[AP] follow moved', row.slug, ':', oldUri, '→', newUri);
+    } catch (e) {
+      console.warn('[AP] move re-follow failed for', row.slug, e && e.message);
+    }
+  }
+  try {
+    const g = db.prepare('SELECT slug, role FROM ap_guardianships WHERE other_uri = ? AND status = ?').all(oldUri, 'accepted');
+    if (g.length) console.warn('[AP] Move touches a guardianship party — left untouched (shaer-tge):', oldUri, '→', g.map((r) => `${r.role}:${r.slug}`).join(', '));
+  } catch { /* table absent on fresh init */ }
+  return 202;
+}
+
 // FEP-633c §5.3 note (authorized fetch): true when `actorUri` is a committed
 /**
@@ -4276,5 +4359,5 @@
   getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote,
   listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
-  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
+  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
   acceptGatedFollow, rejectGatedFollow, isWardGuardian, outboxAudience, sendFollowDecision,
   parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
Index: src/services/i18n.js
===================================================================
--- src/services/i18n.js	(revision 2dd1dc40db2f7dab2c095eeb309e5aaa02369b4b)
+++ src/services/i18n.js	(revision ccaa5301c212451f2e6b7c28c49282055278e961)
@@ -252,4 +252,6 @@
     'asite.links': 'Social / streaming-links',
     'asite.links_hint': 'Getoond als merk-iconen op de profielkop. Voeg er zoveel toe als je wilt.',
+    'asite.aliases': 'Fediverse-aliassen',
+    'asite.aliases_hint': 'Eén per regel: je oude account als @naam@server of als actor-URL. Nodig om volgers van een oud account hierheen te verhuizen; de oude server controleert of dit profiel het oude claimt.',
     'asite.link_add': '+ Link toevoegen',
     'asite.feed_view': 'Feed-weergave',
@@ -1188,4 +1190,6 @@
     'asite.links': 'Social / streaming links',
     'asite.links_hint': 'Shown as brand icons in the profile header. Add as many as you like.',
+    'asite.aliases': 'Fediverse aliases',
+    'asite.aliases_hint': 'One per line: your old account as @name@server or as an actor URL. Needed to move followers from an old account to this one; the old server checks that this profile claims the old one.',
     'asite.link_add': '+ Add link',
     'asite.feed_view': 'Feed display',
@@ -2123,4 +2127,6 @@
     'asite.links': 'Social- / Streaming-Links',
     'asite.links_hint': 'Werden als Marken-Icons im Profilkopf gezeigt. Füge so viele hinzu, wie du möchtest.',
+    'asite.aliases': 'Fediverse-Aliasse',
+    'asite.aliases_hint': 'Einer pro Zeile: dein altes Konto als @name@server oder als Actor-URL. Nötig, um Follower eines alten Kontos hierher umzuziehen; der alte Server prüft, ob dieses Profil das alte beansprucht.',
     'asite.link_add': '+ Link hinzufügen',
     'asite.feed_view': 'Feed-Anzeige',
Index: src/views/pages/admin-site-edit.ejs
===================================================================
--- src/views/pages/admin-site-edit.ejs	(revision 2dd1dc40db2f7dab2c095eeb309e5aaa02369b4b)
+++ src/views/pages/admin-site-edit.ejs	(revision ccaa5301c212451f2e6b7c28c49282055278e961)
@@ -211,4 +211,12 @@
 
       <button type="button" id="profile-link-add" class="btn"><%= t('asite.link_add') %></button>
+    </fieldset>
+
+    <fieldset>
+      <legend><%= t('asite.aliases') %></legend>
+      <p class="form-hint"><%= t('asite.aliases_hint') %></p>
+      <label>
+        <textarea name="ap_aliases" rows="3" placeholder="@oud@mastodon.social&#10;https://andere-klonkt.example/ap/users/naam"><%= apAliases %></textarea>
+      </label>
     </fieldset>
 
Index: test/move-actor.test.js
===================================================================
--- test/move-actor.test.js	(revision ccaa5301c212451f2e6b7c28c49282055278e961)
+++ test/move-actor.test.js	(revision ccaa5301c212451f2e6b7c28c49282055278e961)
@@ -0,0 +1,125 @@
+// FEP-7628 (Move actor, DRAFT) — the inbound half: an account our sites follow
+// announces a move, and our follows travel along. All network legs are
+// injected; the DB is in-memory, like the other AP tests.
+import { test, beforeEach } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://test.example';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const { handleMoveInbox, buildActor } = await import('../src/services/ActivityPubService.js');
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@test', 'x', 'god');
+for (const [id, slug] of [['s1', 'radio'], ['s2', 'blog']]) {
+  db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,?)').run(id, slug, slug, 'u1', id === 's1' ? 1 : 0);
+}
+
+const OLD = 'https://oldhome.example/users/dj';
+const NEW = 'https://newhome.example/users/dj';
+const STRANGER = 'https://elsewhere.example/users/nosy';
+
+// The target actor doc the mover controls; the alsoKnownAs back-reference is
+// the proof both ends belong to the same person.
+const targetActor = (aka = [OLD]) => ({ id: NEW, type: 'Person', inbox: `${NEW}/inbox`, alsoKnownAs: aka });
+const move = (overrides = {}) => ({ '@context': 'https://www.w3.org/ns/activitystreams', id: `${OLD}#move-1`, type: 'Move', actor: OLD, object: OLD, target: NEW, ...overrides });
+
+// Stubs mirror the DB effect of the real followActor/unfollowActor, so the
+// handler's row bookkeeping is exercised without keys or delivery queues.
+let calls;
+const deps = (aka) => ({
+  fetchActorFn: async () => targetActor(aka),
+  unfollowFn: async (site, uri) => { calls.unfollow.push([site.slug, uri]); db.prepare('DELETE FROM ap_following WHERE slug = ? AND actor_uri = ?').run(site.slug, uri); },
+  followFn: async (site, uri, autoBoost) => { calls.follow.push([site.slug, uri, autoBoost]); db.prepare('INSERT OR REPLACE INTO ap_following (slug, actor_uri, status, auto_boost) VALUES (?,?,?,?)').run(site.slug, uri, 'pending', autoBoost ? 1 : 0); },
+});
+
+beforeEach(() => {
+  calls = { unfollow: [], follow: [] };
+  db.prepare('DELETE FROM ap_following').run();
+  db.prepare('DELETE FROM ap_blocks').run();
+  db.prepare('INSERT INTO ap_following (slug, actor_uri, status, auto_boost) VALUES (?,?,?,?)').run('radio', OLD, 'accepted', 1);
+  db.prepare('INSERT INTO ap_following (slug, actor_uri, status, auto_boost) VALUES (?,?,?,?)').run('blog', OLD, 'accepted', 0);
+});
+
+const following = (slug) => db.prepare('SELECT * FROM ap_following WHERE slug = ? ORDER BY actor_uri').all(slug);
+
+test('a third party cannot narrate someone else\'s move', async () => {
+  const res = await handleMoveInbox(move(), { verifiedActor: STRANGER, ...deps() });
+  assert.equal(res, 401);
+  assert.equal(following('radio')[0].actor_uri, OLD);
+  assert.equal(calls.follow.length + calls.unfollow.length, 0);
+});
+
+test('without the alsoKnownAs back-reference nothing moves', async () => {
+  const res = await handleMoveInbox(move(), { verifiedActor: OLD, ...deps([]) });
+  assert.equal(res, 202); // declined, not errored: the sender may be retrying in good faith
+  assert.equal(following('radio')[0].actor_uri, OLD);
+  assert.equal(calls.follow.length + calls.unfollow.length, 0);
+});
+
+test('push mode: both sites re-follow, each keeping its own auto-boost', async () => {
+  const res = await handleMoveInbox(move(), { verifiedActor: OLD, ...deps() });
+  assert.equal(res, 202);
+  assert.deepEqual(calls.unfollow.sort(), [['blog', OLD], ['radio', OLD]]);
+  assert.deepEqual(calls.follow.sort(), [['blog', NEW, false], ['radio', NEW, true]]);
+  assert.equal(following('radio')[0].actor_uri, NEW);
+  assert.equal(following('radio')[0].auto_boost, 1);
+  assert.equal(following('blog')[0].auto_boost, 0);
+});
+
+test('pull mode: the NEW actor may announce the move itself', async () => {
+  const res = await handleMoveInbox(move({ actor: NEW, id: `${NEW}#move-1` }), { verifiedActor: NEW, ...deps() });
+  assert.equal(res, 202);
+  assert.equal(following('radio')[0].actor_uri, NEW);
+});
+
+test('redelivery is idempotent: the second Move finds nothing to do', async () => {
+  await handleMoveInbox(move(), { verifiedActor: OLD, ...deps() });
+  calls = { unfollow: [], follow: [] };
+  const res = await handleMoveInbox(move(), { verifiedActor: OLD, ...deps() });
+  assert.equal(res, 202);
+  assert.equal(calls.follow.length + calls.unfollow.length, 0);
+});
+
+test('a site already following the target is not re-followed, old row still cleaned', async () => {
+  db.prepare('INSERT INTO ap_following (slug, actor_uri, status, auto_boost) VALUES (?,?,?,?)').run('radio', NEW, 'accepted', 0);
+  await handleMoveInbox(move(), { verifiedActor: OLD, ...deps() });
+  assert.deepEqual(calls.follow, [['blog', NEW, false]]); // radio skipped
+  assert.equal(following('radio').length, 1);             // OLD gone, NEW kept
+  assert.equal(following('radio')[0].actor_uri, NEW);
+});
+
+test('a blocked destination is declined: no door opens to a blocked house', async () => {
+  db.prepare('INSERT INTO ap_blocks (slug, target, kind) VALUES (?,?,?)').run('radio', NEW, 'actor');
+  const res = await handleMoveInbox(move(), { verifiedActor: OLD, ...deps() });
+  assert.equal(res, 202);
+  assert.equal(following('radio')[0].actor_uri, OLD); // untouched
+  assert.equal(calls.follow.length + calls.unfollow.length, 0);
+});
+
+test('malformed moves are 400: missing target, or object === target', async () => {
+  assert.equal(await handleMoveInbox(move({ target: undefined }), { verifiedActor: OLD, ...deps() }), 400);
+  assert.equal(await handleMoveInbox(move({ target: OLD }), { verifiedActor: OLD, ...deps() }), 400);
+});
+
+test('an unsigned Move is refused before anything is read', async () => {
+  const res = await handleMoveInbox(move(), { verifiedActor: null, ...deps() });
+  assert.equal(res, 401);
+  assert.equal(following('radio')[0].actor_uri, OLD);
+});
+
+test('the actor publishes alsoKnownAs from ap_aliases; the own id is filtered out', () => {
+  db.prepare("UPDATE sites SET ap_aliases = ? WHERE slug = 'radio'")
+    .run(JSON.stringify(['https://oldhome.example/users/dj', 'https://test.example/ap/users/radio', 42]));
+  const site = db.prepare("SELECT * FROM sites WHERE slug = 'radio'").get();
+  const actor = buildActor('https://test.example', site);
+  assert.deepEqual(actor.alsoKnownAs, ['https://oldhome.example/users/dj']);
+});
+
+test('no aliases set, no alsoKnownAs on the actor', () => {
+  db.prepare("UPDATE sites SET ap_aliases = NULL WHERE slug = 'radio'").run();
+  const site = db.prepare("SELECT * FROM sites WHERE slug = 'radio'").get();
+  assert.equal('alsoKnownAs' in buildActor('https://test.example', site), false);
+});
