Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision 4101c89af6cbf06b37697e9fdb6ddfd13a53af86)
+++ src/config/database.js	(revision fbfd7a199340cd773cd153df2388d88f2084eea2)
@@ -218,4 +218,9 @@
   ensureColumn('sites', 'ap_aliases', 'TEXT');
   ensureColumn('sites', 'moved_to', 'TEXT');   // FEP-7628 slice 2: waarheen dit account vertrok
+  // FEP-1580: staat de ingest-routine nog open? 1 = klaar (en dat is ook de
+  // stand van een site die nooit iets gemigreerd heeft, want er hangt niets).
+  // Derden pollen op deze vlag, dus hij moet ook "er valt niets te wachten"
+  // kunnen zeggen.
+  ensureColumn('sites', 'migration_complete', 'INTEGER DEFAULT 1');
 
   // Per-post noindex + type
@@ -550,4 +555,41 @@
     -- de opstartkosten waren reacties maal tijdlijnrijen.
     CREATE INDEX IF NOT EXISTS idx_ap_timeline_url ON ap_timeline(slug, url);
+    -- FEP-1580: de vertaaltabel van een verhuizing. Per gemigreerd object waar
+    -- het VANDAAN kwam en welke URI het HIER kreeg. Derden lezen deze mapping en
+    -- werken er hun eigen inReplyTo/Like-verwijzingen mee bij; zonder deze tabel
+    -- is "de berichten krijgen nieuwe adressen" een permanent kapotte draad.
+    --
+    -- De spec eist omgekeerd-chronologisch op het moment dat de kopie HIER is
+    -- aangemaakt (niet de oorspronkelijke publicatiedatum). Daarom sorteren we
+    -- op de autoincrement-id en niet op created_at: die heeft secondeprecisie,
+    -- en een ingest zet er tientallen per seconde in.
+    CREATE TABLE IF NOT EXISTS ap_migration (
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      slug TEXT NOT NULL,          -- onze site, de DOELkant van de verhuizing
+      origin TEXT NOT NULL,        -- object-URI op de broninstantie
+      target TEXT NOT NULL,        -- de URI die het object hier kreeg
+      source_actor TEXT NOT NULL DEFAULT '',
+      is_public INTEGER NOT NULL DEFAULT 1,   -- niet-publieke items horen niet in een publieke pagina
+      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+      UNIQUE(slug, origin)
+    );
+    CREATE INDEX IF NOT EXISTS idx_ap_migration_slug ON ap_migration(slug, id DESC);
+    -- De Move-activities zelf, gededupliceerd. FEP-1580 wil dat deze collectie
+    -- een op zichzelf staand bewijs vormt voor de items in ap_migration, met een
+    -- FEP-8b32 integrity proof van de bron-actor plus een kopie van diens
+    -- actor-document. Wij bewaren allebei die stukken al (activity_json en
+    -- actor_json), maar Klonkt kent 8b32 nog niet: zie shaer-j1v0. Zolang dat
+    -- open staat is deze collectie structureel goed en niet verifieerbaar.
+    CREATE TABLE IF NOT EXISTS ap_moves (
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      slug TEXT NOT NULL,
+      move_id TEXT NOT NULL,       -- id van de Move-activity, tevens dedup-sleutel
+      source_actor TEXT NOT NULL,
+      target_actor TEXT NOT NULL,
+      activity_json TEXT NOT NULL,
+      actor_json TEXT,             -- inline kopie van het bron-actordocument
+      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+      UNIQUE(slug, move_id)
+    );
     CREATE TABLE IF NOT EXISTS ap_blocks (
       id INTEGER PRIMARY KEY AUTOINCREMENT,
Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision 4101c89af6cbf06b37697e9fdb6ddfd13a53af86)
+++ src/routes/activitypub.js	(revision fbfd7a199340cd773cd153df2388d88f2084eea2)
@@ -20,4 +20,5 @@
 import OAuth from '../services/OAuthService.js';
 import * as Guardianship from '../services/guardianship/index.js';
+import * as Migration from '../services/MigrationService.js';
 import { getPrimarySite } from '../middleware/site.js';
 import multer from 'multer';
@@ -655,18 +656,29 @@
 // clients read it here instead of keeping their own state. Actor-kind
 // blocks only (domain blocks are instance policy, not an Orbit member).
-router.get('/ap/users/:slug/blocked', (req, res) => {
+//
+// FEP-1580 zet deze deur één spleet verder open: de bronkant MOET de blokkades
+// beschikbaar maken voor de instantie waar je NAARTOE verhuist, zodat je
+// zichtbaarheidsvoorkeuren meeverhuizen. De doelkant haalt ze als eerste op,
+// want ze bepalen wat de rest te zien krijgt. Geen nieuwe collectie: deze
+// bestond al en staat al op de actor, alleen de toegang verbreedt.
+router.get('/ap/users/:slug/blocked', async (req, res) => {
   const auth = OAuth.verifyBearer(req.headers.authorization);
-  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
+  let slug = (auth && auth.site.slug === req.params.slug) ? auth.site.slug : null;
+  if (!slug && req.headers['signature']) {
+    const verified = await AP.verifyRequest(req).catch(() => null);
+    if (verified && verified.id && AP.isMoveTarget(req.params.slug, verified.id)) slug = req.params.slug;
+  }
+  if (!slug) return res.status(403).end();
   const base = baseUrl(req);
-  const items = AP.listBlocks(auth.site.slug)
+  const items = AP.listBlocks(slug)
     .filter((b) => b.kind === 'actor')
     .map((b) => b.target);
   AP.sendAP(res, {
     '@context': AP.AP_CONTEXT,
-    id: `${base}/ap/users/${auth.site.slug}/blocked`,
+    id: `${base}/ap/users/${slug}/blocked`,
     type: 'OrderedCollection',
     totalItems: items.length,
     orderedItems: items,
-  });
+  }, 'private, no-store');
 });
 
@@ -1031,4 +1043,42 @@
 });
 
+// ── FEP-1580: de vertaaltabel van een verhuizing ──────────────────
+//
+// Publiek leesbaar, want dat is het hele doel: een derde die een oude URI in
+// zijn database heeft leest hier wat de nieuwe is. Zonder deze collectie blijft
+// elke reactie op een verhuisd bericht naar een dood adres wijzen.
+//
+// Niet-publieke items komen er alleen in voor een lezer die ze mocht zien. De
+// spec: Moves voor objecten die niet aan as:Public gericht zijn MOGEN NIET
+// publiek getoond worden. Een lijst met de URIs van je fan-only posts is een
+// lek, ook al staat de inhoud er niet bij.
+router.get('/ap/users/:slug/migration', async (req, res) => {
+  const site = publicSite(req.params.slug);
+  if (!site) return res.status(404).end();
+  let alles = false;
+  const auth = OAuth.verifyBearer(req.headers.authorization);
+  if (auth && auth.site.slug === site.slug) alles = true;
+  else if (req.headers['signature']) {
+    const v = await AP.verifyRequest(req).catch(() => null);
+    // Een geverifieerde volger zat in het publiek van de fan-only posts, dus
+    // die mag ook weten waar ze heen zijn.
+    if (v && v.id && AP.outboxAudience(site.slug, { verifiedActor: v.id }) === 'friend') alles = true;
+  }
+  AP.sendAP(res, Migration.buildMigration(baseUrl(req), site, { page: paginaNr(req), alles }),
+    alles ? 'private, no-store' : undefined);
+});
+
+// De Moves die de vertaaltabel rechtvaardigen. Altijd publiek: een bewijs dat
+// je moet kunnen nakijken heeft niets aan een slot.
+//
+// LET OP: zonder FEP-8b32 (shaer-j1v0) staat hier geen handtekening onder. De
+// collectie is structureel goed en niet verifieerbaar, en een derde die de spec
+// streng volgt mag hem daarom weigeren. Bewust geen leeg proof-veld erbij.
+router.get('/ap/users/:slug/moves', (req, res) => {
+  const site = publicSite(req.params.slug);
+  if (!site) return res.status(404).end();
+  AP.sendAP(res, Migration.buildMoves(baseUrl(req), site));
+});
+
 // ── Featured (pinned posts → Mastodon "Featured" tab) ─────────────
 router.get('/ap/users/:slug/featured', (req, res) => {
Index: src/routes/admin-migrate.js
===================================================================
--- src/routes/admin-migrate.js	(revision fbfd7a199340cd773cd153df2388d88f2084eea2)
+++ src/routes/admin-migrate.js	(revision fbfd7a199340cd773cd153df2388d88f2084eea2)
@@ -0,0 +1,193 @@
+/**
+ * admin-migrate.js — Beheer → Migreren: je posts en media naar een andere Klonkt.
+ *
+ * Dezelfde machinerie als scripts/export-archive.mjs en import-archive.mjs, maar via
+ * de webinterface, zodat verhuizen geen SSH-toegang meer vraagt. De services doen
+ * het werk; deze routes zijn de deur.
+ *
+ * DRIE KEUZES DIE ER TOE DOEN:
+ *
+ * 1. IMPORTEREN GAAT ALTIJD EERST DROOG. Je krijgt een verslag te zien en pas
+ *    daarna een knop die het echt doet. Een archief inlezen is niet terug te
+ *    draaien, en "ik dacht dat ik alleen keek" is de duurste vergissing hier.
+ *
+ * 2. OVERSCHRIJVEN IS EEN APARTE, GEWAARSCHUWDE KEUZE. importArchive gebruikt
+ *    INSERT OR REPLACE, en dat verwijdert ELKE rij die een unieke sleutel schendt:
+ *    een post met een ander id maar dezelfde slug gaat dus stil mee (zie shaer-snv5).
+ *    Standaard slaat hij bestaande posts over, en dat blijft ook de standaard hier.
+ *
+ * 3. HET ARCHIEF WORDT IN GEHEUGEN GEBOUWD. buildArchive levert een Map van
+ *    Buffers; bij veel media is dat een forse allocatie. Daarom een harde grens en
+ *    een eerlijke melding in plaats van een proces dat omvalt. Streamen is
+ *    shaer-190t en hoort daar thuis, niet hier.
+ */
+import express from 'express';
+import multer from 'multer';
+import fs from 'fs';
+import path from 'path';
+import { renderPage } from '../middleware/render.js';
+import { requireGod } from '../middleware/auth.js';
+import ActivityPubService from '../services/ActivityPubService.js';
+import { safeFetch, signedGetJson, noteId, noteVisibility } from '../services/ActivityPubService.js';
+import HtmlSanitizerService from '../services/HtmlSanitizerService.js';
+import { MEDIA_ROOT } from '../config/paths.js';
+import * as Migration from '../services/MigrationService.js';
+import { buildArchive, zipArchive } from '../services/ArchiveExportService.js';
+import { readArchiveZip, importArchive } from '../services/ArchiveImportService.js';
+
+const router = express.Router();
+
+// Ruim genoeg voor een gewone site met media, klein genoeg om een instance niet om
+// te laten vallen op een archief dat iemand toevallig aanlevert.
+const MAX_UPLOAD = 512 * 1024 * 1024;
+const MAX_EXPORT = 512 * 1024 * 1024;
+
+const upload = multer({
+  storage: multer.memoryStorage(),
+  limits: { fileSize: MAX_UPLOAD, files: 1 },
+}).single('archief');
+
+const mb = (n) => (n < 1024 * 1024 ? `${Math.max(1, Math.round(n / 1024))} kB` : `${(n / (1024 * 1024)).toFixed(1)} MB`);
+
+/** Wat zou er in een export zitten? Droog gebouwd, dus zonder zip. */
+function tellen(site) {
+  if (!site) return { telling: null, fout: null };
+  try {
+    const r = buildArchive(site.slug);
+    let bytes = 0;
+    for (const buf of r.files.values()) bytes += buf.length;
+    return { telling: { ...r.counts, bytes, groot: bytes > MAX_EXPORT, ontbrekend: r.missing.length }, fout: null };
+  } catch (e) { return { telling: null, fout: e && e.message }; }
+}
+
+/**
+ * Waar zouden we vandaan kunnen halen? De alias die we zelf claimen (FEP-7628
+ * alsoKnownAs). Dat is niet toevallig hetzelfde veld als waar de ingest op
+ * controleert: het is de helft van de afspraak die je hier al gezet hebt.
+ */
+function bronKandidaat(site) {
+  try {
+    const aka = JSON.parse((site && site.ap_aliases) || '[]');
+    return Array.isArray(aka) ? aka.find((u) => typeof u === 'string' && /^https?:\/\//i.test(u)) || null : null;
+  } catch { return null; }
+}
+
+/** De pagina. Toont wat er in een export zou zitten, zonder hem te bouwen. */
+router.get('/', requireGod, (req, res) => {
+  // Droog bouwen om te tellen. Dat kost hetzelfde geheugen als een echte export,
+  // dus hier meteen de grens bewaken in plaats van pas bij de download.
+  const site = res.locals.site;
+  const { telling, fout } = tellen(site);
+  renderPage(req, res, 'pages/admin-migrate', {
+    pageTitle: 'Migreren', bodyClass: 'on-special',
+    telling, fout, mb,
+    verslag: null,
+    bron: bronKandidaat(site),
+    mig: site ? Migration.migrationStatus(site.slug) : null,
+    haalVerslag: null,
+    success: req.query.success || null, error: req.query.error || fout || null,
+  });
+});
+
+/**
+ * FEP-1580: haal de berichten rechtstreeks bij je oude Klonkt op.
+ *
+ * Geen code, geen token: de autorisatie IS de Move die je al gedaan hebt. De
+ * oude instantie geeft ons zijn eigen kijkrechten omdat `moved_to` daar naar
+ * ons wijst, en dat veld staat er alleen als wij hem toen al in alsoKnownAs
+ * hadden. Beide kanten hebben dus ooit ja gezegd, en dat is precies waarom
+ * hier geen tweede vertrouwensmechanisme bij hoeft.
+ *
+ * De ingest kijkt die afspraak zelf nog een keer na, in beide richtingen. Dit
+ * is een deur, geen controle.
+ */
+router.post('/pull', requireGod, async (req, res) => {
+  const site = res.locals.site;
+  if (!site) return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen site'));
+  const opgegeven = String(req.body && req.body.bron ? req.body.bron : '').trim();
+  let r = null;
+  try {
+    r = await Migration.ingestFromSource(site, {
+      sourceUri: opgegeven || null,
+      deps: {
+        getJson: signedGetJson, safeFetch, mediaRoot: MEDIA_ROOT, fs, path, noteId, noteVisibility,
+        sanitize: (h) => HtmlSanitizerService.sanitize(h || ''),
+      },
+    });
+  } catch (e) {
+    r = { error: 'crash', melding: e && e.message };
+  }
+  const { telling } = tellen(site);
+  renderPage(req, res, 'pages/admin-migrate', {
+    pageTitle: 'Migreren', bodyClass: 'on-special',
+    telling, fout: null, mb, verslag: null,
+    bron: opgegeven || bronKandidaat(site),
+    mig: Migration.migrationStatus(site.slug),
+    haalVerslag: r,
+    success: (r && !r.error) ? 'Opgehaald' : null,
+    error: null,
+  });
+});
+
+/** Download het archief als zip. */
+router.get('/export', requireGod, (req, res) => {
+  const site = res.locals.site;
+  if (!site) return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen site'));
+  try {
+    const r = buildArchive(site.slug);
+    const zip = zipArchive(r.files);
+    if (zip.length > MAX_EXPORT) {
+      return res.redirect('/admin/migrate?error=' + encodeURIComponent(
+        `Het archief is ${mb(zip.length)} en dat is te groot voor de webinterface. Gebruik scripts/export-archive.mjs op de server.`));
+    }
+    const naam = `klonkt-${site.slug}-${new Date().toISOString().slice(0, 10)}.zip`;
+    res.set('Content-Type', 'application/zip');
+    res.set('Content-Disposition', `attachment; filename="${naam}"`);
+    // Privé: dit is je hele site, niets voor een cache onderweg.
+    res.set('Cache-Control', 'private, no-store');
+    res.send(zip);
+  } catch (e) {
+    res.redirect('/admin/migrate?error=' + encodeURIComponent(`Exporteren mislukt: ${e && e.message}`));
+  }
+});
+
+/**
+ * Importeren. Zonder `echt=1` is dit een DROOGLOOP: hij leest het archief, meldt
+ * wat er zou gebeuren, en raakt niets aan.
+ */
+router.post('/import', requireGod, upload, async (req, res) => {
+  const site = res.locals.site;
+  if (!site) return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen site'));
+  if (!req.file || !req.file.buffer || !req.file.buffer.length) {
+    return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen bestand ontvangen'));
+  }
+
+  const echt = String(req.body.echt || '') === '1';
+  const overschrijf = String(req.body.overschrijf || '') === '1';
+
+  let verslag = null;
+  let fout = null;
+  try {
+    const files = readArchiveZip(req.file.buffer);
+    verslag = importArchive(files, {
+      slug: site.slug,
+      dryRun: !echt,
+      overwrite: overschrijf,
+    });
+  } catch (e) {
+    fout = e && e.message;
+  }
+
+  // Bij een DROOGLOOP blijven we op de pagina met het verslag, zodat je kunt
+  // besluiten. Bij een echte import ook, maar dan met de uitkomst.
+  renderPage(req, res, 'pages/admin-migrate', {
+    pageTitle: 'Migreren', bodyClass: 'on-special',
+    telling: tellen(site).telling, fout: null, mb,
+    bron: bronKandidaat(site), mig: Migration.migrationStatus(site.slug), haalVerslag: null,
+    verslag: verslag ? { ...verslag, echt, overschrijf, bestand: req.file.originalname, bytes: req.file.buffer.length } : null,
+    success: (echt && verslag && !fout) ? 'Archief geïmporteerd' : null,
+    error: fout ? `Importeren mislukt: ${fout}` : null,
+  });
+});
+
+export default router;
Index: src/server.js
===================================================================
--- src/server.js	(revision 4101c89af6cbf06b37697e9fdb6ddfd13a53af86)
+++ src/server.js	(revision fbfd7a199340cd773cd153df2388d88f2084eea2)
@@ -51,4 +51,5 @@
 import guardianRoutes from './routes/guardian.js';
 import adminMediaRoutes from './routes/admin-media.js';
+import adminMigrateRoutes from './routes/admin-migrate.js';
 import circleRoutes from './routes/circle.js';
 import epkRoutes from './routes/epk.js';
@@ -397,4 +398,5 @@
 }
 app.use('/admin/media', adminMediaRoutes); // image library + cleanup (works in lite mode too)
+app.use('/admin/migrate', adminMigrateRoutes); // posts + media naar/van een andere Klonkt
 app.use('/admin/sites', adminSitesRoutes);
 app.use('/admin/users', adminUsersRoutes);
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 4101c89af6cbf06b37697e9fdb6ddfd13a53af86)
+++ src/services/ActivityPubService.js	(revision fbfd7a199340cd773cd153df2388d88f2084eea2)
@@ -391,4 +391,10 @@
     // separate state.
     blocked: `${id}/blocked`,
+    // FEP-1580: de vertaaltabel van een verhuizing plus de Moves die hem
+    // rechtvaardigen. Deze twee staan er ALTIJD, ook leeg, en dat is met opzet:
+    // de FEP wijst er apart op dat "een verhuizing zonder objecten" en "een
+    // server die dit niet kent" anders niet uit elkaar te houden zijn.
+    migration: `${id}/migration`,
+    moves: `${id}/moves`,
     // FEP-633c §2: shaer:guardians / shaer:isGuardian / shaer:queues
     // (guardianship module owns these).
@@ -1839,4 +1845,8 @@
   if (aud === 'public') return true;
   if (aud === 'direct' || !site || !actorUri) return false;
+  // FEP-1580: dezelfde regel als in outboxAudience, en hier net zo hard nodig.
+  // De outbox geeft de LIJST vrij; zonder deze tak strandt de doelinstantie
+  // alsnog op elke losse Note die niet publiek is.
+  if (isMoveTarget(site.slug, actorUri)) return true;
   try {
     const blocked = db.prepare("SELECT 1 FROM ap_blocks WHERE slug = ? AND kind = 'actor' AND target = ?").get(site.slug, actorUri);
@@ -5406,5 +5416,5 @@
  * (request-target) host date, the set verifyRequest checks.
  */
-async function signedGetJson(slug, url, onStatus) {
+export async function signedGetJson(slug, url, onStatus) {
   try {
     const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
@@ -5982,4 +5992,9 @@
   if (!verifiedActor) return 'public';
   if (isBlockedAny(verifiedActor)) return 'blocked';
+  // FEP-1580, Source Instance: wie ondertekend vraagt namens de actor waar wij
+  // NAARTOE verhuisd zijn, moet behandeld worden alsof wij het zelf vragen.
+  // Anders kan de nieuwe instantie alleen het publieke deel ophalen en verhuist
+  // je fan-only geschiedenis niet mee.
+  if (isMoveTarget(slug, verifiedActor)) return 'friend';
   try {
     if (db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND actor_uri = ?').get(slug, verifiedActor)) return 'friend';
@@ -5987,4 +6002,27 @@
   if (isWardGuardian(slug, verifiedActor)) return 'friend';
   return 'public';
+}
+
+/**
+ * FEP-1580, de hele autorisatie van de bronkant in één predicaat.
+ *
+ * De spec zegt: behandel een verzoek dat namens de DOEL-actor getekend is alsof
+ * de BRON-actor het deed, voor zichtbaarheid en toegang. Wij hangen dat aan
+ * `moved_to`, en dat mag omdat moveAccount() `no_backreference` weigert: het
+ * veld komt er alleen te staan als de doel-actor ons al in `alsoKnownAs` had.
+ * Dus staat er iets, dan heeft iemand met beheer op BEIDE kanten dat gewild.
+ * Een typefout kan hier niet binnenkomen, want die haalt de move zelf niet.
+ *
+ * Dat dit veilig is leunt op de keyId-binding in verifyRequest (shaer-xd8i):
+ * zonder die controle kon een actor tekenen met de sleutel van een buurman op
+ * dezelfde host, en dan is "wie tekende dit" te zacht om je hele geschiedenis
+ * aan af te geven.
+ */
+export function isMoveTarget(slug, actorUri) {
+  if (!slug || !actorUri) return false;
+  try {
+    const row = db.prepare('SELECT moved_to FROM sites WHERE slug = ?').get(slug);
+    return !!(row && row.moved_to && row.moved_to === actorUri);
+  } catch { return false; }
 }
 
@@ -6716,4 +6754,8 @@
 export default {
   movedLock,
+  // FEP-1580 bronkant. Vergeet je hem hier, dan werpt elke route die hem
+  // aanroept een 500 en lijkt het alsof de poort dicht staat terwijl hij
+  // ontbreekt (precies hoe movedLock zich een dag eerder verstopte).
+  isMoveTarget, signedGetJson,
   AP_CONTEXT, getOrCreateKeys, apWants, sendAP, actorId, noteId, stripLeadingMentions, pagedCollection,
   deriveHandle, localSlugOf, outboxSlice, PAGINA_GROOTTE,
Index: src/services/ArchiveImportService.js
===================================================================
--- src/services/ArchiveImportService.js	(revision 4101c89af6cbf06b37697e9fdb6ddfd13a53af86)
+++ src/services/ArchiveImportService.js	(revision fbfd7a199340cd773cd153df2388d88f2084eea2)
@@ -26,4 +26,5 @@
 import { MEDIA_ROOT } from '../config/paths.js';
 import { FORMAT_VERSION, parseFollowingCsv } from './ArchiveExportService.js';
+import * as Migration from './MigrationService.js';
 
 const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
@@ -255,5 +256,5 @@
     }
 
-    schrijf.push({ soort: 'post', id: nieuwId, oudId, obj: o });
+    schrijf.push({ soort: 'post', id: nieuwId, oudId, obj: o, oudeUri: o.id || null });
     rapport.posts += 1;
   }
@@ -360,4 +361,24 @@
       }
     }
+
+    // FEP-1580: een import uit een export is GEEN apart geval. De spec zegt
+    // met zoveel woorden dat objecten uit een geëxporteerde collectie net zo
+    // behandeld moeten worden als objecten die van de bron zijn opgehaald.
+    // Dus vult ook deze weg de vertaaltabel, en werkt de reactie van een derde
+    // op een verhuisd bericht straks net zo goed bij als bij een live ingest.
+    //
+    // Alleen zinnig als de ids VERANDERD zijn: bleven ze gelijk, dan wijst de
+    // oude URI al naar het goede object en valt er niets te vertalen.
+    if (!idsBehouden && eigenOrigin) {
+      for (const s of schrijf) {
+        if (s.soort !== 'post' || !s.oudeUri) continue;
+        Migration.recordMigrated(site.slug, {
+          origin: s.oudeUri,
+          target: `${eigenOrigin}/ap/notes/${encodeURIComponent(s.id)}`,
+          sourceActor: manifest.actor || '',
+          isPublic: !(s.obj && (s.obj['shaer:fanOnly'] || s.obj['shaer:apVisibility'] === 'direct')),
+        });
+      }
+    }
   })();
 
Index: src/services/MigrationService.js
===================================================================
--- src/services/MigrationService.js	(revision fbfd7a199340cd773cd153df2388d88f2084eea2)
+++ src/services/MigrationService.js	(revision fbfd7a199340cd773cd153df2388d88f2084eea2)
@@ -0,0 +1,454 @@
+/**
+ * MigrationService.js — FEP-1580: je OBJECTEN verhuizen bij een Move.
+ *
+ * FEP-7628 verhuist je volgers en zegt zelf dat de rest een ander probleem is.
+ * Dit is dat andere probleem: na een Move stonden je berichten nog op de oude
+ * instantie, en elke reactie van een derde wees naar een URI die verdwijnt zodra
+ * dat domein opgezegd wordt.
+ *
+ * DRIE DINGEN OM TE WETEN VOOR JE HIERIN LEEST:
+ *
+ * 1. DE AUTORISATIE IS DE MOVE, NIET EEN CODE. De bronkant staat in
+ *    ActivityPubService.isMoveTarget: een ondertekend verzoek namens de actor
+ *    waar de bron naartoe verhuisde telt als de bron zelf. Dat mag omdat
+ *    moveAccount() `no_backreference` weigert, dus `moved_to` staat er alleen
+ *    als iemand met beheer op BEIDE kanten dat wilde. Hier in dit bestand zit
+ *    de DOELkant, die van die toestemming gebruikmaakt.
+ *
+ * 2. NIEUWE IDS ZIJN GEEN BUG, DE VERTAALTABEL IS HET ANTWOORD. Een gemigreerd
+ *    bericht krijgt hier een eigen URI, want het staat nu op een ander domein.
+ *    De `migration`-collectie mapt oud naar nieuw en derden lezen die om hun
+ *    eigen verwijzingen bij te werken. Zonder die collectie is de draad kapot,
+ *    met die collectie is het een verhuisbericht.
+ *
+ * 3. ER GAAT GEEN Create DE DEUR UIT. De spec is daar expliciet over, en het is
+ *    ook gewoon logisch: je volgers hebben deze berichten jaren geleden al
+ *    gezien. Een ingest van driehonderd posts die als driehonderd nieuwe posts
+ *    de tijdlijn in klettert is geen verhuizing maar spam.
+ *
+ * WAT HIER ONTBREEKT: FEP-8b32 integrity proofs (shaer-j1v0). De `moves`-
+ * collectie hoort ondertekend te zijn en de Moves erin horen een proof van de
+ * bron-actor te dragen. Klonkt kent 8b32 nog niet. We bewaren wel alle
+ * grondstof (de rauwe activity en het actordocument), zodat het later alleen
+ * ondertekenen is. Bewust geen leeg proof-veld: een derde die het controleert
+ * wordt dan misleid, en dat is erger dan een veld dat ontbreekt.
+ */
+import crypto from 'crypto';
+import db from '../config/database.js';
+import { AP_CONTEXT, actorId, pagedCollection } from './ap-core.js';
+
+// ── Vertaaltabel ──────────────────────────────────────────────────
+
+const stmts = {};
+function q(naam, sql) { return (stmts[naam] ||= db.prepare(sql)); }
+
+/** Leg vast dat `origin` hier `target` werd. Idempotent: opnieuw draaien mag. */
+export function recordMigrated(slug, { origin, target, sourceActor = '', isPublic = true } = {}) {
+  if (!slug || !origin || !target) return false;
+  try {
+    q('ins', `INSERT INTO ap_migration (slug, origin, target, source_actor, is_public)
+              VALUES (?, ?, ?, ?, ?)
+              ON CONFLICT(slug, origin) DO UPDATE SET target = excluded.target`)
+      .run(slug, String(origin), String(target), String(sourceActor || ''), isPublic ? 1 : 0);
+    return true;
+  } catch (e) {
+    console.warn('[FEP-1580] mapping niet opgeslagen:', origin, e && e.message);
+    return false;
+  }
+}
+
+/**
+ * De items, nieuwste kopie eerst.
+ *
+ * `alles` is alleen waar voor een geverifieerde lezer uit het publiek van de
+ * niet-publieke objecten. De spec: Moves voor objecten die niet aan as:Public
+ * gericht zijn MOGEN NIET publiek getoond worden. Een migration-collectie die
+ * de URIs van je fan-only posts opsomt is een lek, ook zonder de inhoud.
+ */
+export function migrationItems(slug, { alles = false } = {}) {
+  try {
+    const sql = `SELECT origin, target, source_actor FROM ap_migration
+                 WHERE slug = ?${alles ? '' : ' AND is_public = 1'} ORDER BY id DESC`;
+    return db.prepare(sql).all(slug);
+  } catch { return []; }
+}
+
+export function migrationCount(slug, { alles = false } = {}) {
+  try {
+    const sql = `SELECT COUNT(*) n FROM ap_migration WHERE slug = ?${alles ? '' : ' AND is_public = 1'}`;
+    return db.prepare(sql).get(slug).n;
+  } catch { return 0; }
+}
+
+/** Is deze URI hier al binnen? Houdt een tweede ingest-ronde goedkoop. */
+export function alGemigreerd(slug, origin) {
+  try { return !!db.prepare('SELECT 1 FROM ap_migration WHERE slug = ? AND origin = ?').get(slug, String(origin)); } catch { return false; }
+}
+
+// ── De Move-activities ────────────────────────────────────────────
+
+export function recordMove(slug, { moveId, sourceActor, targetActor, activity, actorDoc = null } = {}) {
+  if (!slug || !moveId || !sourceActor || !targetActor) return false;
+  try {
+    q('insMove', `INSERT INTO ap_moves (slug, move_id, source_actor, target_actor, activity_json, actor_json)
+                  VALUES (?, ?, ?, ?, ?, ?)
+                  ON CONFLICT(slug, move_id) DO NOTHING`)
+      .run(slug, String(moveId), String(sourceActor), String(targetActor),
+        JSON.stringify(activity || {}), actorDoc ? JSON.stringify(actorDoc) : null);
+    return true;
+  } catch (e) {
+    console.warn('[FEP-1580] Move niet opgeslagen:', moveId, e && e.message);
+    return false;
+  }
+}
+
+export function moveRows(slug) {
+  try { return db.prepare('SELECT * FROM ap_moves WHERE slug = ? ORDER BY id').all(slug); } catch { return []; }
+}
+
+// ── Stand van zaken ───────────────────────────────────────────────
+
+export function migrationComplete(slug) {
+  try {
+    const r = db.prepare('SELECT migration_complete FROM sites WHERE slug = ?').get(slug);
+    // Geen kolom of geen rij telt als "klaar": een site die nooit verhuisde
+    // heeft niets openstaan, en derden moeten niet eeuwig blijven pollen.
+    return !r || r.migration_complete === null || r.migration_complete === undefined ? true : !!r.migration_complete;
+  } catch { return true; }
+}
+
+export function setMigrationComplete(slug, klaar) {
+  try { db.prepare('UPDATE sites SET migration_complete = ? WHERE slug = ?').run(klaar ? 1 : 0, slug); } catch { /* kolom ontbreekt op een oude db */ }
+}
+
+// ── De collecties ─────────────────────────────────────────────────
+
+/**
+ * De `migration`-collectie. Items zijn Move-activities per OBJECT (niet per
+ * actor): origin is de oude URI, target de nieuwe.
+ *
+ * De spec wil URI-verwijzingen in origin/target in plaats van ingesloten
+ * objecten, en paginering. `pagedCollection` doet dat al voor de rest van
+ * Klonkt, dus die gebruiken we ook hier.
+ */
+export function buildMigration(base, site, { page = false, alles = false } = {}) {
+  const me = actorId(base, site.slug);
+  const id = `${me}/migration`;
+  const rows = migrationItems(site.slug, { alles });
+  const items = rows.map((r) => ({
+    type: 'Move',
+    actor: r.source_actor || undefined,
+    origin: r.origin,
+    target: r.target,
+  }));
+  return pagedCollection(id, items, {
+    page,
+    extra: {
+      attributedTo: me,
+      moves: `${me}/moves`,
+      migrationComplete: migrationComplete(site.slug),
+    },
+  });
+}
+
+/**
+ * De `moves`-collectie: de Move-activities zelf, met het bron-actordocument
+ * ingesloten zoals de spec aanraadt ("Source instances SHOULD inline the source
+ * Actor object"), zodat een lezer de proof kan nakijken zonder de bron nog te
+ * kunnen bereiken. Dat laatste is precies het geval waarvoor dit bestaat.
+ *
+ * Zonder FEP-8b32 (shaer-j1v0) ontbreekt de handtekening. Zie de kop.
+ */
+export function buildMoves(base, site) {
+  const me = actorId(base, site.slug);
+  const rows = moveRows(site.slug);
+  const orderedItems = rows.map((r) => {
+    let act = {};
+    try { act = JSON.parse(r.activity_json) || {}; } catch { /* onleesbaar, dan de kale vorm hieronder */ }
+    let actorDoc = null;
+    try { actorDoc = r.actor_json ? JSON.parse(r.actor_json) : null; } catch { /* idem */ }
+    return {
+      id: r.move_id,
+      type: 'Move',
+      origin: r.source_actor,
+      target: r.target_actor,
+      actor: actorDoc || r.source_actor,
+      ...(act.published ? { published: act.published } : {}),
+    };
+  });
+  return {
+    '@context': AP_CONTEXT,
+    id: `${me}/moves`,
+    type: 'OrderedCollection',
+    attributedTo: me,
+    totalItems: orderedItems.length,
+    orderedItems,
+  };
+}
+
+// ── Wat de UI wil weten ───────────────────────────────────────────
+
+export function migrationStatus(slug) {
+  return {
+    total: migrationCount(slug, { alles: true }),
+    publiek: migrationCount(slug),
+    moves: moveRows(slug).length,
+    complete: migrationComplete(slug),
+  };
+}
+
+/** Een id dat nergens mee botst, in de vorm die de rest van Klonkt gebruikt. */
+export function nieuwId() { return crypto.randomUUID(); }
+
+// ── De ingest: van de bron hierheen ───────────────────────────────
+
+/** Vrije slug binnen deze site. Botst hij, dan -2, -3, enzovoort. */
+function vrijeSlug(siteId, basis) {
+  const schoon = String(basis || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'bericht';
+  const bestaat = db.prepare('SELECT 1 FROM posts WHERE site_id = ? AND slug = ?');
+  if (!bestaat.get(siteId, schoon)) return schoon;
+  for (let n = 2; n < 500; n++) if (!bestaat.get(siteId, `${schoon}-${n}`)) return `${schoon}-${n}`;
+  return `${schoon}-${crypto.randomBytes(4).toString('hex')}`;
+}
+
+/** De laatste padcomponent van een URI, als beginpunt voor een slug. */
+function slugUitUri(uri) {
+  try { return decodeURIComponent(new URL(uri).pathname.split('/').filter(Boolean).pop() || ''); } catch { return ''; }
+}
+
+const AFBEELDING = /^image\//i;
+
+/**
+ * De titel terugwinnen uit de content.
+ *
+ * Een AS2 Note heeft geen titel: Mastodon negeert `name`, dus Klonkt bakt de
+ * titel als vetgedrukte eerste alinea IN de content (zie buildNote). Over de
+ * lijn is een titel dus geen veld maar een vorm. Doen we hier niets, dan komt
+ * elk bericht titelloos aan en heet het naar zijn id.
+ *
+ * Daarom draaien we precies onze eigen bak terug: alleen als de content BEGINT
+ * met een alinea die niets anders bevat dan vetgedrukte tekst. Dat is de exacte
+ * vorm die buildNote maakt. Een bericht van elders dat toevallig zo begint
+ * verliest die regel niet, hij verhuist naar het titelveld en staat straks
+ * gewoon weer bovenaan.
+ */
+function titelUitContent(html) {
+  const m = /^\s*<p>\s*<strong>([\s\S]*?)<\/strong>\s*<\/p>/i.exec(String(html || ''));
+  if (!m) return { titel: null, rest: html };
+  const titel = m[1].replace(/<[^>]+>/g, '').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').trim();
+  if (!titel || titel.length > 300) return { titel: null, rest: html };
+  return { titel, rest: String(html).slice(m[0].length) };
+}
+
+/**
+ * Haal een bijlage op en zet hem lokaal neer.
+ *
+ * safeFetch is de SSRF-veilige kant van Klonkt; hier is dat geen formaliteit,
+ * want de URL komt van een andere server. Een bron die ons naar 127.0.0.1 wijst
+ * moet stranden, ook als die bron "van onszelf" is.
+ */
+async function haalBijlage(url, { safeFetch, mediaRoot, fs, path, maxBytes }) {
+  const r = await safeFetch(url, { headers: { accept: '*/*' } }).catch(() => null);
+  if (!r || !r.ok) return null;
+  const buf = Buffer.from(await r.arrayBuffer());
+  if (!buf.length || buf.length > maxBytes) return null;
+  const type = String(r.headers.get('content-type') || '').split(';')[0].trim() || 'application/octet-stream';
+  const ext = (() => {
+    const uit = slugUitUri(url);
+    const m = /\.([a-z0-9]{1,5})$/i.exec(uit);
+    if (m) return m[1].toLowerCase();
+    return (type.split('/')[1] || 'bin').replace(/[^a-z0-9]/gi, '').slice(0, 5) || 'bin';
+  })();
+  const naam = `${crypto.randomUUID()}.${ext}`;
+  const rel = `migrated/${naam}`;
+  const abs = path.join(mediaRoot, 'migrated', naam);
+  fs.mkdirSync(path.dirname(abs), { recursive: true });
+  fs.writeFileSync(abs, buf);
+  return { url: `/media/${rel}`, mediaType: type, size: buf.length, filename: naam, storage_path: abs };
+}
+
+/**
+ * FEP-1580 ingest-routine, de doelkant.
+ *
+ * De autorisatie wordt hier niet verzonnen maar NAGEKEKEN, en in beide
+ * richtingen, precies zoals de spec het voor derden voorschrijft: `movedTo` op
+ * de bron moet naar ons wijzen EN wij moeten de bron in `alsoKnownAs` hebben.
+ * Eén kant is een bewering, twee kanten is een afspraak. Zou ik alleen op onze
+ * eigen alsoKnownAs afgaan, dan kon iedereen die zichzelf een alias geeft de
+ * geschiedenis van een vreemde opeisen.
+ *
+ * `deps` is er voor de test: die moet dit kunnen draaien zonder netwerk.
+ */
+export async function ingestFromSource(site, {
+  sourceUri = null, max = 1000, maxBytes = 25 * 1024 * 1024, deps = {},
+} = {}) {
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  if (!base || !site || !site.slug) return { error: 'config' };
+  const me = actorId(base, site.slug);
+
+  const {
+    getJson = null, safeFetch = null, mediaRoot = null, fs = null, path = null, noteId = null,
+    sanitize = (h) => h,
+    // Standaard 'followers': kan iets niet als publiek bewezen worden, dan
+    // hoort het niet in de publieke vertaaltabel. Fail-closed, want dit is een
+    // privacygrens en niet een weergavedetail.
+    noteVisibility = () => 'followers',
+  } = deps;
+  const zichtbaarheid = noteVisibility;
+  if (!getJson || !noteId) return { error: 'config' };
+
+  // 1. Welke bron? Zonder opgave: de alias die we zelf claimen.
+  let bron = sourceUri && /^https?:\/\//i.test(sourceUri) ? sourceUri : null;
+  if (!bron) {
+    try {
+      const aka = JSON.parse(site.ap_aliases || '[]');
+      bron = Array.isArray(aka) ? aka.find((u) => typeof u === 'string' && /^https?:\/\//i.test(u)) || null : null;
+    } catch { /* stukke ap_aliases telt als geen alias */ }
+  }
+  if (!bron) return { error: 'no_source' };
+
+  // 2 + 3. Het bron-actordocument, en de wegwijzer die naar ONS moet wijzen.
+  const bronActor = await getJson(site.slug, bron);
+  if (!bronActor || !bronActor.id) return { error: 'unreachable' };
+  if (bronActor.movedTo !== me) return { error: 'not_moved_here', movedTo: bronActor.movedTo || null };
+
+  // 4. En de terugverwijzing van onze kant, zodat het een afspraak is.
+  const eigenAka = (() => {
+    try { const a = JSON.parse(site.ap_aliases || '[]'); return Array.isArray(a) ? a : []; } catch { return []; }
+  })();
+  if (!eigenAka.includes(bronActor.id)) return { error: 'no_backreference' };
+
+  // 5 + 6. Vastleggen dat dit een migratie is, en de deur openzetten voor derden.
+  recordMove(site.slug, {
+    moveId: `${bronActor.id}#move`, sourceActor: bronActor.id, targetActor: me,
+    activity: { type: 'Move', actor: bronActor.id, object: bronActor.id, target: me },
+    actorDoc: bronActor,
+  });
+  setMigrationComplete(site.slug, false);
+
+  const rapport = {
+    bron: bronActor.id, posts: 0, overgeslagen: 0, media: 0, mediaMislukt: 0,
+    blocks: 0, waarschuwingen: [],
+  };
+
+  try {
+    // 7. BLOKKADES EERST. De spec is daar streng over, en terecht: ze bepalen
+    //    wie de rest te zien krijgt. Andersom importeer je even je hele
+    //    geschiedenis zichtbaar voor iemand die je nou juist buiten wilde.
+    if (bronActor.blocked) {
+      const coll = await getJson(site.slug, typeof bronActor.blocked === 'string' ? bronActor.blocked : bronActor.blocked.id);
+      const lijst = (coll && (coll.orderedItems || coll.items)) || [];
+      for (const b of Array.isArray(lijst) ? lijst : []) {
+        const uri = typeof b === 'string' ? b : (b && (b.object || b.id));
+        if (!uri || !/^https?:\/\//i.test(String(uri))) continue;
+        try {
+          db.prepare("INSERT OR IGNORE INTO ap_blocks (slug, target, kind, label) VALUES (?, ?, 'actor', NULL)").run(site.slug, String(uri));
+          rapport.blocks++;
+        } catch { /* tabel ontbreekt op een verse db */ }
+      }
+    } else {
+      rapport.waarschuwingen.push('de bron gaf geen blokkadelijst, zichtbaarheidsvoorkeuren komen niet mee');
+    }
+
+    // 8. De outbox aflopen. Pagineren zoals de rest van Klonkt dat doet.
+    if (!bronActor.outbox) return { ...rapport, error: 'no_outbox' };
+    let pagina = await getJson(site.slug, typeof bronActor.outbox === 'string' ? bronActor.outbox : bronActor.outbox.id);
+    if (pagina && pagina.first && !(pagina.orderedItems || pagina.items)) {
+      pagina = await getJson(site.slug, typeof pagina.first === 'string' ? pagina.first : pagina.first.id);
+    }
+
+    const insPost = db.prepare(`INSERT INTO posts
+      (id, site_id, slug, author_id, title, content, excerpt, status, cover_image_url,
+       pinned, type, tags, published_at, created_at, updated_at, fan_only, nsfw, language,
+       content_warning, ap_visibility, c2s_attachments, origin_server)
+      VALUES (@id, @site_id, @slug, @author_id, @title, @content, NULL, 'published', @cover_image_url,
+       0, 'post', @tags, @published_at, @published_at, @updated_at, @fan_only, @nsfw, @language,
+       @content_warning, @ap_visibility, @c2s_attachments, 'migrated')`);
+
+    let gezien = 0;
+    while (pagina && gezien < max) {
+      const items = (pagina.orderedItems || pagina.items) || [];
+      for (const it of Array.isArray(items) ? items : []) {
+        if (gezien >= max) break;
+        const o = (it && typeof it.object === 'object' && it.object) ? it.object : it;
+        if (!o || !o.id) continue;
+        if (o.type && !['Note', 'Article', 'Question'].includes(o.type)) continue;
+        if (o.inReplyTo) continue;                                    // toplevel; antwoorden hangen aan hun ouder
+        const auteur = typeof o.attributedTo === 'string' ? o.attributedTo : (o.attributedTo && o.attributedTo.id);
+        if (auteur && auteur !== bronActor.id) continue;              // alleen wat van HEM was
+        gezien++;
+        if (alGemigreerd(site.slug, o.id)) { rapport.overgeslagen++; continue; }
+
+        // Media eerst, want een post die naar een plaatje wijst dat we niet
+        // hebben opgehaald is een halve post. Mislukt een bijlage, dan gaat de
+        // post wel door en staat het in het verslag.
+        const bijlagen = Array.isArray(o.attachment) ? o.attachment : [];
+        const binnen = [];
+        if (safeFetch && fs && path && mediaRoot) {
+          for (const a of bijlagen.slice(0, 20)) {
+            const u = a && (typeof a === 'string' ? a : (a.url && (typeof a.url === 'string' ? a.url : a.url.href)));
+            if (!u || !/^https?:\/\//i.test(String(u))) continue;
+            const g = await haalBijlage(String(u), { safeFetch, mediaRoot, fs, path, maxBytes }).catch(() => null);
+            if (!g) { rapport.mediaMislukt++; rapport.waarschuwingen.push(`bijlage niet opgehaald: ${u}`); continue; }
+            binnen.push({ ...g, naam: (a && a.name) || null, type: (a && a.mediaType) || g.mediaType });
+            rapport.media++;
+            try {
+              db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?, ?, ?, ?, ?, ?)')
+                .run(crypto.randomUUID(), site.id, g.filename, g.mediaType, g.size, g.storage_path);
+            } catch { /* media-rij is administratie, het bestand staat er */ }
+          }
+        }
+
+        const id = crypto.randomUUID();
+        const cover = binnen.find((b) => AFBEELDING.test(b.type || ''));
+        const rest = binnen.filter((b) => b !== cover);
+        // De titel zit in de content, niet in een veld (zie titelUitContent).
+        const { titel, rest: body } = o.name ? { titel: o.name, rest: o.content || '' } : titelUitContent(o.content || '');
+        // De slug uit de MENSELIJKE url, niet uit de AP-id. Zo houdt het bericht
+        // hetzelfde webadres als op de oude instantie, en blijft een link die
+        // iemand ergens plakte kloppen op het nieuwe domein.
+        const basisSlug = slugUitUri(o.url || '') || o.name || slugUitUri(o.id) || id;
+        // De publicatiedatum blijft die van het origineel. De spec eist dat, en
+        // het is ook het enige eerlijke: het bericht is niet vandaag geschreven.
+        insPost.run({
+          id, site_id: site.id, slug: vrijeSlug(site.id, basisSlug),
+          author_id: site.owner_id, title: titel || null, content: sanitize(body || ''),
+          cover_image_url: cover ? cover.url : null,
+          tags: Array.isArray(o.tag) ? o.tag.filter((t) => t && t.type === 'Hashtag').map((t) => String(t.name || '').replace(/^#/, '')).filter(Boolean).join(', ') || null : null,
+          published_at: o.published || null, updated_at: o.updated || o.published || null,
+          fan_only: 0, nsfw: o.sensitive ? 1 : 0,
+          language: (o.contentMap && Object.keys(o.contentMap)[0]) || null,
+          content_warning: o.summary || null,
+          ap_visibility: null,
+          c2s_attachments: rest.length ? JSON.stringify(rest.map((b) => ({ url: b.url, mediaType: b.type, name: b.naam || undefined }))) : null,
+        });
+        recordMigrated(site.slug, {
+          origin: o.id, target: noteId(base, id), sourceActor: bronActor.id,
+          // Publiek in de zin van de spec: gericht aan as:Public. Zo niet, dan
+          // hoort deze regel niet in een publiek leesbare migration-pagina.
+          //
+          // Via noteVisibility en niet met een eigen test op '#Public': die kent
+          // ook de schrijfwijzen 'as:Public' en 'Public', en de rest van Klonkt
+          // beslist er al mee. Een tweede, dunnere versie van dezelfde vraag is
+          // precies hoe twee antwoorden uit elkaar gaan lopen.
+          isPublic: zichtbaarheid(o) === 'public',
+        });
+        rapport.posts++;
+      }
+      const volgende = pagina.next;
+      if (!volgende || gezien >= max) break;
+      pagina = await getJson(site.slug, typeof volgende === 'string' ? volgende : volgende.id);
+    }
+    if (gezien >= max) rapport.waarschuwingen.push(`gestopt bij ${max} berichten, draai het nog eens voor de rest`);
+  } catch (e) {
+    // 9-bij-mislukking: de vlag blijft OPEN staan. Derden blijven dan kijken,
+    // en dat is precies goed, want er is nog werk.
+    console.warn('[FEP-1580] ingest afgebroken:', e && e.message);
+    return { ...rapport, error: 'partial', melding: e && e.message };
+  }
+
+  // 9. Klaar. Nu pas mag een derde stoppen met kijken.
+  setMigrationComplete(site.slug, true);
+  console.log('[FEP-1580] ingest klaar:', site.slug, '<-', bronActor.id, rapport.posts, 'berichten,', rapport.media, 'bestanden');
+  return rapport;
+}
Index: src/services/ap-core.js
===================================================================
--- src/services/ap-core.js	(revision 4101c89af6cbf06b37697e9fdb6ddfd13a53af86)
+++ src/services/ap-core.js	(revision fbfd7a199340cd773cd153df2388d88f2084eea2)
@@ -64,4 +64,15 @@
     // Question stays valid JSON-LD (a strict processor would otherwise drop votersCount).
     votersCount: 'toot:votersCount',
+    // FEP-1580 (objectmigratie bij een Move). FEP-7628 verhuist je VOLGERS en
+    // zegt dat zelf met zoveel woorden: de objecten zijn een ander probleem, en
+    // dit is de FEP waar dat geregeld wordt. De namespace is die van de FEP zelf
+    // (aangemeld via FEP-888d). De CURIE van de collectie is `migration:migration`,
+    // door de auteur zelf "maybe unhelpfully" genoemd; wij emitteren de JSON-sleutel
+    // `migration`, want daar leest een consument op.
+    migration: { '@id': 'https://w3id.org/fep/1580/migration', '@type': '@id' },
+    moves: { '@id': 'https://w3id.org/fep/1580/moves', '@type': '@id' },
+    migrationComplete: 'https://w3id.org/fep/1580/migrationComplete',
+    migratedFrom: { '@id': 'https://w3id.org/fep/1580/migratedFrom', '@type': '@id' },
+    migratedAt: 'https://w3id.org/fep/1580/migratedAt',
     // Kanaal-vocabulaire (shaer-0nh). Funkwhale declareert `category` niet
     // inline maar via zijn eigen remote context https://funkwhale.audio/ns, en
Index: src/services/i18n.js
===================================================================
--- src/services/i18n.js	(revision 4101c89af6cbf06b37697e9fdb6ddfd13a53af86)
+++ src/services/i18n.js	(revision fbfd7a199340cd773cd153df2388d88f2084eea2)
@@ -76,5 +76,5 @@
     'admin.b_media': '🎬 Media', 'admin.t_media': 'Media', 'admin.media_images': 'Afbeeldingen', 'admin.media_videos': "Video's", 'admin.videos_count': "video's", 'admin.videos_empty': "Nog geen video's geupload.", 'admin.videos_del_confirm': 'Deze video verwijderen?', 'admin.media_count': 'afbeeldingen', 'admin.media_unused': 'ongebruikt', 'admin.media_cleanup': 'Ongebruikt opruimen', 'admin.media_cleanup_confirm': 'Alle ongebruikte afbeeldingen verwijderen?', 'admin.media_empty': 'Nog geen afbeeldingen geüpload.', 'admin.media_copy': 'Kopieer URL', 'admin.media_del_confirm': 'Deze afbeelding verwijderen?',
     'admin.b_playlists': '📃 Playlists', 'admin.b_comments': '💬 Reacties', 'admin.b_seo': '🔎 SEO',
-    'admin.b_settings': '⚙️ Instellingen', 'admin.b_newpost': '✍️ Nieuwe post', 'admin.b_look': '🎨 Uiterlijk',
+    'admin.b_settings': '⚙️ Instellingen', "mig.title": "Migreren", "mig.pull_title": "Ophalen bij je oude Klonkt", "mig.pull_hint": "Als je al verhuisd bent, kan deze Klonkt je berichten rechtstreeks bij de oude ophalen. Je hoeft geen code over te typen: de verhuizing zelf is de sleutel, want beide kanten hebben elkaar toen al erkend.", "mig.pull_source": "Adres van je oude account", "mig.pull_source_hint": "Ingevuld vanuit je aliassen. Klopt dit niet, pas het dan aan.", "mig.pull_btn": "Berichten ophalen", "mig.pull_done": "Opgehaald", "mig.pull_failed": "Ophalen lukte niet", "mig.r_blocks": "blokkades overgenomen", "mig.state": "{n} berichten hebben een verwijzing van hun oude adres.", "mig.state_done": "De lijst is compleet.", "mig.state_busy": "De lijst is nog niet compleet.", "mig.e_no_source": "Er is geen oud account bekend. Vul het adres hierboven in.", "mig.e_unreachable": "De oude Klonkt is niet bereikbaar.", "mig.e_not_moved_here": "Dat account is niet naar deze Klonkt verhuisd, dus het geeft ons niets.", "mig.e_no_backreference": "Dit account noemt dat oude adres niet als eigen alias. Zet het eerst bij je aliassen, anders kan niemand zien dat jij het bent.", "mig.e_no_outbox": "De oude Klonkt heeft geen berichtenlijst.", "mig.e_partial": "Halverwege gestopt. Wat binnen is blijft staan; probeer het nog eens.", "mig.e_config": "Deze Klonkt weet zijn eigen adres niet.", "mig.e_crash": "Er ging iets onverwachts mis.", "mig.e_points_at": "Dat account wijst naar:", "mig.lead": "Neem je berichten en media mee naar een andere Klonkt, of haal ze hiernaartoe. Hetzelfde archief dat het serverscript maakt, maar dan hier.", "mig.export_title": "Meenemen", "mig.export_hint": "Dit maakt een zip met je berichten, de reacties erop, en de media die erbij hoort.", "mig.c_posts": "berichten", "mig.c_replies": "reacties", "mig.c_media": "mediabestanden", "mig.c_following": "volgingen", "mig.c_size": "groot", "mig.missing": "Let op: van {n} mediaverwijzing(en) staat het bestand niet meer op schijf. Die gaan dus niet mee.", "mig.too_big": "Dit archief is te groot voor de webinterface. Gebruik scripts/export-archive.mjs op de server.", "mig.export_btn": "Archief downloaden (.zip)", "mig.export_none": "Er valt nog niets te exporteren.", "mig.import_title": "Hierheen halen", "mig.import_hint": "Kies een archief. Hij kijkt eerst wat er zou gebeuren en verandert nog niets.", "mig.file_label": "Archiefbestand (.zip)", "mig.overwrite_label": "Bestaande berichten overschrijven", "mig.overwrite_hint": "Standaard wordt een bericht dat er al staat overgeslagen. Overschrijven verwijdert ook een bericht met dezelfde webadres-naam maar een ander id, en dat komt niet terug.", "mig.check_btn": "Controleren", "mig.check_hint": "Dit verandert nog niets. Je krijgt eerst te zien wat er zou gebeuren.", "mig.r_dry": "Wat er zou gebeuren", "mig.r_done": "Ge\u00efmporteerd", "mig.r_would": "zouden erbij komen", "mig.r_imported": "erbij gekomen", "mig.r_skipped": "overgeslagen (staan er al)", "mig.r_overwritten": "overschreven", "mig.r_media": "mediabestanden", "mig.r_media_missing": "media ontbrak in het archief", "mig.r_new_ids": "Dit archief komt van een ander adres. De berichten krijgen daarom nieuwe adressen en gelden voor de fediverse als nieuwe berichten; reacties die elders naar de oude adressen wijzen komen niet mee.", "mig.r_confirm_hint": "Ziet het er goed uit? Kies hetzelfde bestand nog een keer om het echt te doen.", "mig.r_confirm_btn": "Nu echt importeren", "mig.follows_title": "Wie je volgt", "mig.follows_hint": "Je volglijst zit ook in het archief, en je kunt hem los downloaden en terugzetten bij Connect.", "mig.follows_btn": "Naar Connect", "admin.b_migrate": "\ud83d\udce6 Migreren", 'admin.b_newpost': '✍️ Nieuwe post', 'admin.b_look': '🎨 Uiterlijk',
     'admin.t_admin': 'Beheer', 'admin.t_settings': 'Instellingen', 'admin.t_audio': 'Audiotracks', 'admin.t_epk': 'Perskit bewerken', 'admin.t_newsletter': 'Nieuwsbrief', 'admin.t_playlists': 'Afspeellijsten', 'admin.t_seo': 'SEO', 'admin.t_shows': 'Agenda', 'admin.t_sites': 'Sites', 'admin.t_newsite': 'Nieuwe site', 'admin.t_editsite': 'Bewerken: {title}', 'admin.t_stats': 'Statistieken', 'admin.t_updates': 'Updates', 'admin.t_users': 'Gebruikers', 'admin.t_hub': 'Mijn Klonkt Hub', 'admin.t_manual': 'Handleiding', 'aset.premium_gate': '{feature} is premium — koppel Patreon in Beheer → Instellingen.',
     'admin.b_makesite': '🎨 Maak je site aan', 'admin.b_circle': '🔗 Cirkel', 'admin.b_stats': '📊 Statistieken',
@@ -1044,5 +1044,5 @@
     'admin.b_media': '🎬 Media', 'admin.t_media': 'Media', 'admin.media_images': 'Images', 'admin.media_videos': 'Videos', 'admin.videos_count': 'videos', 'admin.videos_empty': 'No videos uploaded yet.', 'admin.videos_del_confirm': 'Delete this video?', 'admin.media_count': 'images', 'admin.media_unused': 'unused', 'admin.media_cleanup': 'Delete unused', 'admin.media_cleanup_confirm': 'Delete all unused images?', 'admin.media_empty': 'No images uploaded yet.', 'admin.media_copy': 'Copy URL', 'admin.media_del_confirm': 'Delete this image?',
     'admin.b_playlists': '📃 Playlists', 'admin.b_comments': '💬 Comments', 'admin.b_seo': '🔎 SEO',
-    'admin.b_settings': '⚙️ Settings', 'admin.b_newpost': '✍️ New post', 'admin.b_look': '🎨 Appearance',
+    'admin.b_settings': '⚙️ Settings', "mig.title": "Migrate", "mig.pull_title": "Fetch from your old Klonkt", "mig.pull_hint": "If you already moved, this Klonkt can fetch your posts straight from the old one. No code to type over: the move itself is the key, because both sides already recognised each other back then.", "mig.pull_source": "Address of your old account", "mig.pull_source_hint": "Filled in from your aliases. Change it if this is not right.", "mig.pull_btn": "Fetch posts", "mig.pull_done": "Fetched", "mig.pull_failed": "Fetching did not work", "mig.r_blocks": "blocks carried over", "mig.state": "{n} posts have a pointer from their old address.", "mig.state_done": "The list is complete.", "mig.state_busy": "The list is not complete yet.", "mig.e_no_source": "No old account is known. Fill in the address above.", "mig.e_unreachable": "The old Klonkt cannot be reached.", "mig.e_not_moved_here": "That account did not move to this Klonkt, so it gives us nothing.", "mig.e_no_backreference": "This account does not list that old address as its own alias. Add it to your aliases first, otherwise nobody can see it is you.", "mig.e_no_outbox": "The old Klonkt has no post list.", "mig.e_partial": "Stopped halfway. What arrived stays; try again.", "mig.e_config": "This Klonkt does not know its own address.", "mig.e_crash": "Something unexpected went wrong.", "mig.e_points_at": "That account points at:", "mig.lead": "Take your posts and media to another Klonkt, or bring them here. The same archive the server script makes, but from here.", "mig.export_title": "Take with you", "mig.export_hint": "This builds a zip with your posts, their replies, and the media that belongs to them.", "mig.c_posts": "posts", "mig.c_replies": "replies", "mig.c_media": "media files", "mig.c_following": "follows", "mig.c_size": "in size", "mig.missing": "Note: for {n} media reference(s) the file is no longer on disk. Those will not travel.", "mig.too_big": "This archive is too large for the web interface. Use scripts/export-archive.mjs on the server.", "mig.export_btn": "Download archive (.zip)", "mig.export_none": "Nothing to export yet.", "mig.import_title": "Bring here", "mig.import_hint": "Pick an archive. It first shows what would happen and changes nothing yet.", "mig.file_label": "Archive file (.zip)", "mig.overwrite_label": "Overwrite existing posts", "mig.overwrite_hint": "By default a post that already exists is skipped. Overwriting also deletes a post with the same web address but a different id, and that does not come back.", "mig.check_btn": "Check", "mig.check_hint": "This changes nothing yet. You will see what would happen first.", "mig.r_dry": "What would happen", "mig.r_done": "Imported", "mig.r_would": "would be added", "mig.r_imported": "added", "mig.r_skipped": "skipped (already here)", "mig.r_overwritten": "overwritten", "mig.r_media": "media files", "mig.r_media_missing": "media missing from the archive", "mig.r_new_ids": "This archive comes from another address. The posts get new addresses and count as new posts on the fediverse; replies elsewhere pointing at the old addresses will not follow.", "mig.r_confirm_hint": "Looks right? Pick the same file once more to actually do it.", "mig.r_confirm_btn": "Import for real", "mig.follows_title": "Who you follow", "mig.follows_hint": "Your following list is in the archive too, and you can download and restore it separately under Connect.", "mig.follows_btn": "Go to Connect", "admin.b_migrate": "\ud83d\udce6 Migrate", 'admin.b_newpost': '✍️ New post', 'admin.b_look': '🎨 Appearance',
     'admin.t_admin': 'Admin', 'admin.t_settings': 'Settings', 'admin.t_audio': 'Audio tracks', 'admin.t_epk': 'Edit press kit', 'admin.t_newsletter': 'Newsletter', 'admin.t_playlists': 'Playlists', 'admin.t_seo': 'SEO', 'admin.t_shows': 'Agenda', 'admin.t_sites': 'Sites', 'admin.t_newsite': 'New site', 'admin.t_editsite': 'Edit: {title}', 'admin.t_stats': 'Statistics', 'admin.t_updates': 'Updates', 'admin.t_users': 'Users', 'admin.t_hub': 'My Klonkt Hub', 'admin.t_manual': 'Manual', 'aset.premium_gate': '{feature} is a premium feature — connect Patreon in Admin → Settings.',
     'admin.b_makesite': '🎨 Create your site', 'admin.b_circle': '🔗 Circle', 'admin.b_stats': '📊 Statistics',
@@ -2006,5 +2006,5 @@
     'admin.b_media': '🎬 Medien', 'admin.t_media': 'Medien', 'admin.media_images': 'Bilder', 'admin.media_videos': 'Videos', 'admin.videos_count': 'Videos', 'admin.videos_empty': 'Noch keine Videos hochgeladen.', 'admin.videos_del_confirm': 'Dieses Video löschen?', 'admin.media_count': 'Bilder', 'admin.media_unused': 'ungenutzt', 'admin.media_cleanup': 'Ungenutzte löschen', 'admin.media_cleanup_confirm': 'Alle ungenutzten Bilder löschen?', 'admin.media_empty': 'Noch keine Bilder hochgeladen.', 'admin.media_copy': 'URL kopieren', 'admin.media_del_confirm': 'Dieses Bild löschen?',
     'admin.b_playlists': '📃 Playlists', 'admin.b_comments': '💬 Kommentare', 'admin.b_seo': '🔎 SEO',
-    'admin.b_settings': '⚙️ Einstellungen', 'admin.b_newpost': '✍️ Neuer Beitrag', 'admin.b_look': '🎨 Aussehen',
+    'admin.b_settings': '⚙️ Einstellungen', "mig.title": "Migrieren", "mig.pull_title": "Von deinem alten Klonkt holen", "mig.pull_hint": "Wenn du schon umgezogen bist, kann dieses Klonkt deine Beitr\u00e4ge direkt beim alten holen. Kein Code zum Abtippen: der Umzug selbst ist der Schl\u00fcssel, denn beide Seiten haben sich damals schon anerkannt.", "mig.pull_source": "Adresse deines alten Kontos", "mig.pull_source_hint": "Aus deinen Aliassen eingetragen. Stimmt das nicht, \u00e4ndere es.", "mig.pull_btn": "Beitr\u00e4ge holen", "mig.pull_done": "Geholt", "mig.pull_failed": "Holen hat nicht geklappt", "mig.r_blocks": "Blockierungen \u00fcbernommen", "mig.state": "{n} Beitr\u00e4ge haben einen Verweis von ihrer alten Adresse.", "mig.state_done": "Die Liste ist vollst\u00e4ndig.", "mig.state_busy": "Die Liste ist noch nicht vollst\u00e4ndig.", "mig.e_no_source": "Es ist kein altes Konto bekannt. Trage die Adresse oben ein.", "mig.e_unreachable": "Das alte Klonkt ist nicht erreichbar.", "mig.e_not_moved_here": "Dieses Konto ist nicht zu diesem Klonkt umgezogen, also gibt es uns nichts.", "mig.e_no_backreference": "Dieses Konto f\u00fchrt die alte Adresse nicht als eigenen Alias. Trage sie erst bei deinen Aliassen ein, sonst kann niemand sehen, dass du es bist.", "mig.e_no_outbox": "Das alte Klonkt hat keine Beitragsliste.", "mig.e_partial": "Auf halbem Weg gestoppt. Was da ist, bleibt; versuche es noch einmal.", "mig.e_config": "Dieses Klonkt kennt seine eigene Adresse nicht.", "mig.e_crash": "Etwas Unerwartetes ist schiefgegangen.", "mig.e_points_at": "Dieses Konto verweist auf:", "mig.lead": "Nimm deine Beitr\u00e4ge und Medien zu einem anderen Klonkt mit, oder hole sie hierher. Dasselbe Archiv wie das Server-Skript, nur von hier aus.", "mig.export_title": "Mitnehmen", "mig.export_hint": "Das erstellt ein Zip mit deinen Beitr\u00e4gen, den Antworten darauf und den zugeh\u00f6rigen Medien.", "mig.c_posts": "Beitr\u00e4ge", "mig.c_replies": "Antworten", "mig.c_media": "Mediendateien", "mig.c_following": "Folge ich", "mig.c_size": "gro\u00df", "mig.missing": "Achtung: Bei {n} Medienverweis(en) liegt die Datei nicht mehr auf der Platte. Die kommen nicht mit.", "mig.too_big": "Dieses Archiv ist zu gro\u00df f\u00fcr die Weboberfl\u00e4che. Nutze scripts/export-archive.mjs auf dem Server.", "mig.export_btn": "Archiv herunterladen (.zip)", "mig.export_none": "Noch nichts zu exportieren.", "mig.import_title": "Hierher holen", "mig.import_hint": "W\u00e4hle ein Archiv. Es zeigt zuerst, was passieren w\u00fcrde, und \u00e4ndert noch nichts.", "mig.file_label": "Archivdatei (.zip)", "mig.overwrite_label": "Vorhandene Beitr\u00e4ge \u00fcberschreiben", "mig.overwrite_hint": "Standardm\u00e4\u00dfig wird ein bereits vorhandener Beitrag \u00fcbersprungen. \u00dcberschreiben l\u00f6scht auch einen Beitrag mit derselben Webadresse aber anderer id, und der kommt nicht zur\u00fcck.", "mig.check_btn": "Pr\u00fcfen", "mig.check_hint": "Das \u00e4ndert noch nichts. Du siehst zuerst, was passieren w\u00fcrde.", "mig.r_dry": "Was passieren w\u00fcrde", "mig.r_done": "Importiert", "mig.r_would": "k\u00e4men dazu", "mig.r_imported": "dazugekommen", "mig.r_skipped": "\u00fcbersprungen (schon vorhanden)", "mig.r_overwritten": "\u00fcberschrieben", "mig.r_media": "Mediendateien", "mig.r_media_missing": "Medien fehlten im Archiv", "mig.r_new_ids": "Dieses Archiv kommt von einer anderen Adresse. Die Beitr\u00e4ge bekommen neue Adressen und gelten im Fediverse als neue Beitr\u00e4ge; Antworten anderswo auf die alten Adressen kommen nicht mit.", "mig.r_confirm_hint": "Sieht gut aus? W\u00e4hle dieselbe Datei noch einmal, um es wirklich zu tun.", "mig.r_confirm_btn": "Jetzt wirklich importieren", "mig.follows_title": "Wem du folgst", "mig.follows_hint": "Deine Folgeliste ist auch im Archiv, und du kannst sie unter Connect separat herunterladen und zur\u00fcckspielen.", "mig.follows_btn": "Zu Connect", "admin.b_migrate": "\ud83d\udce6 Migrieren", 'admin.b_newpost': '✍️ Neuer Beitrag', 'admin.b_look': '🎨 Aussehen',
     'admin.t_admin': 'Verwaltung', 'admin.t_settings': 'Einstellungen', 'admin.t_audio': 'Audiotracks', 'admin.t_epk': 'Pressemappe bearbeiten', 'admin.t_newsletter': 'Newsletter', 'admin.t_playlists': 'Playlists', 'admin.t_seo': 'SEO', 'admin.t_shows': 'Termine', 'admin.t_sites': 'Sites', 'admin.t_newsite': 'Neue Site', 'admin.t_editsite': 'Bearbeiten: {title}', 'admin.t_stats': 'Statistiken', 'admin.t_updates': 'Updates', 'admin.t_users': 'Benutzer', 'admin.t_hub': 'Mein Klonkt Hub', 'admin.t_manual': 'Handbuch', 'aset.premium_gate': '{feature} ist eine Premium-Funktion — verbinde Patreon in Verwaltung → Einstellungen.',
     'admin.b_makesite': '🎨 Seite erstellen', 'admin.b_circle': '🔗 Kreis', 'admin.b_stats': '📊 Statistik',
Index: src/views/pages/admin-migrate.ejs
===================================================================
--- src/views/pages/admin-migrate.ejs	(revision fbfd7a199340cd773cd153df2388d88f2084eea2)
+++ src/views/pages/admin-migrate.ejs	(revision fbfd7a199340cd773cd153df2388d88f2084eea2)
@@ -0,0 +1,176 @@
+<div class="container mig-wrap">
+  <h1><%= t('mig.title') %></h1>
+  <p class="mig-lead"><%= t('mig.lead') %></p>
+
+  <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success %></div><% } %>
+  <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error %></div><% } %>
+
+  <%# ── Exporteren ─────────────────────────────────────────────── %>
+  <section class="mig-card">
+    <h2><%= t('mig.export_title') %></h2>
+    <p class="form-hint"><%= t('mig.export_hint') %></p>
+
+    <% if (telling) { %>
+      <ul class="mig-counts">
+        <li><strong><%= telling.posts %></strong> <%= t('mig.c_posts') %></li>
+        <li><strong><%= telling.replies %></strong> <%= t('mig.c_replies') %></li>
+        <li><strong><%= telling.media %></strong> <%= t('mig.c_media') %></li>
+        <% if (typeof telling.following === 'number') { %>
+          <li><strong><%= telling.following %></strong> <%= t('mig.c_following') %></li>
+        <% } %>
+        <li><strong><%= mb(telling.bytes) %></strong> <%= t('mig.c_size') %></li>
+      </ul>
+
+      <% if (telling.ontbrekend) { %>
+        <%# Niets stils: media die op schijf ontbreekt wordt geteld en gemeld, want
+            een archief dat stilzwijgend minder bevat is erger dan een waarschuwing. %>
+        <p class="alert alert-error"><%= t('mig.missing', { n: telling.ontbrekend }) %></p>
+      <% } %>
+
+      <% if (telling.groot) { %>
+        <p class="alert alert-error"><%= t('mig.too_big') %></p>
+      <% } else { %>
+        <p><a class="btn btn-primary" href="/admin/migrate/export"><%= t('mig.export_btn') %></a></p>
+      <% } %>
+    <% } else { %>
+      <p class="form-hint"><%= t('mig.export_none') %></p>
+    <% } %>
+  </section>
+
+  <%# ── Rechtstreeks ophalen (FEP-1580) ────────────────────────── %>
+  <section class="mig-card">
+    <h2><%= t('mig.pull_title') %></h2>
+    <p class="form-hint"><%= t('mig.pull_hint') %></p>
+
+    <% if (haalVerslag) { %>
+      <div class="mig-report<%= haalVerslag.error ? '' : ' is-done' %>">
+        <% if (haalVerslag.error) { %>
+          <h3><%= t('mig.pull_failed') %></h3>
+          <%# Elke weigering krijgt een eigen zin. "Er ging iets mis" laat je
+              raden welke van de twee kanten van de afspraak ontbreekt, en dat
+              is precies wat je moet weten om het op te lossen. %>
+          <p><%= t('mig.e_' + haalVerslag.error) !== 'mig.e_' + haalVerslag.error
+                   ? t('mig.e_' + haalVerslag.error) : (haalVerslag.melding || haalVerslag.error) %></p>
+          <% if (haalVerslag.error === 'not_moved_here' && haalVerslag.movedTo) { %>
+            <p class="form-hint"><%= t('mig.e_points_at') %> <code><%= haalVerslag.movedTo %></code></p>
+          <% } %>
+        <% } else { %>
+          <h3><%= t('mig.pull_done') %></h3>
+          <ul class="mig-counts">
+            <li><strong><%= haalVerslag.posts %></strong> <%= t('mig.r_imported') %></li>
+            <% if (haalVerslag.overgeslagen) { %><li><strong><%= haalVerslag.overgeslagen %></strong> <%= t('mig.r_skipped') %></li><% } %>
+            <% if (haalVerslag.media) { %><li><strong><%= haalVerslag.media %></strong> <%= t('mig.r_media') %></li><% } %>
+            <% if (haalVerslag.blocks) { %><li><strong><%= haalVerslag.blocks %></strong> <%= t('mig.r_blocks') %></li><% } %>
+          </ul>
+        <% } %>
+        <% if (haalVerslag.waarschuwingen && haalVerslag.waarschuwingen.length) { %>
+          <ul class="mig-warnlist">
+            <% haalVerslag.waarschuwingen.slice(0, 6).forEach(function (w) { %><li><%= w %></li><% }); %>
+          </ul>
+        <% } %>
+      </div>
+    <% } %>
+
+    <form method="post" action="/admin/migrate/pull">
+      <label for="mig-bron"><%= t('mig.pull_source') %></label>
+      <input type="url" id="mig-bron" name="bron" value="<%= bron || '' %>"
+             placeholder="https://oude-klonkt.nl/ap/users/jij" class="mig-input">
+      <p class="form-hint"><%= t('mig.pull_source_hint') %></p>
+      <button type="submit" class="btn"><%= t('mig.pull_btn') %></button>
+    </form>
+
+    <% if (mig && (mig.total || mig.moves)) { %>
+      <p class="form-hint mig-state">
+        <%= t('mig.state', { n: mig.total }) %>
+        <%= mig.complete ? t('mig.state_done') : t('mig.state_busy') %>
+      </p>
+    <% } %>
+  </section>
+
+  <%# ── Importeren ─────────────────────────────────────────────── %>
+  <section class="mig-card">
+    <h2><%= t('mig.import_title') %></h2>
+    <p class="form-hint"><%= t('mig.import_hint') %></p>
+
+    <% if (verslag) { %>
+      <div class="mig-report<%= verslag.echt ? ' is-done' : '' %>">
+        <h3><%= verslag.echt ? t('mig.r_done') : t('mig.r_dry') %></h3>
+        <p class="form-hint"><%= verslag.bestand %> &middot; <%= mb(verslag.bytes) %></p>
+        <ul class="mig-counts">
+          <li><strong><%= verslag.posts %></strong> <%= verslag.echt ? t('mig.r_imported') : t('mig.r_would') %></li>
+          <li><strong><%= verslag.overgeslagen %></strong> <%= t('mig.r_skipped') %></li>
+          <% if (verslag.overschreven) { %><li><strong><%= verslag.overschreven %></strong> <%= t('mig.r_overwritten') %></li><% } %>
+          <% if (verslag.mediaMissing) { %><li><strong><%= verslag.mediaMissing %></strong> <%= t('mig.r_media_missing') %></li><% } %>
+          <% if (verslag.media) { %><li><strong><%= verslag.media %></strong> <%= t('mig.r_media') %></li><% } %>
+        </ul>
+        <% if (verslag.waarschuwingen && verslag.waarschuwingen.length) { %>
+          <ul class="mig-warnlist">
+            <% verslag.waarschuwingen.slice(0, 6).forEach(function (w) { %><li><%= w %></li><% }); %>
+          </ul>
+        <% } %>
+        <% if (verslag.idsBehouden === false) { %>
+          <%# Dit is de belangrijkste regel van het hele verslag: komt het archief
+              van een ANDER adres, dan krijgen de posts nieuwe id's en zijn het
+              nieuwe objecten voor de fediverse. %>
+          <p class="alert alert-error"><%= t('mig.r_new_ids') %></p>
+        <% } %>
+
+        <% if (!verslag.echt && verslag.posts > 0) { %>
+          <form method="post" action="/admin/migrate/import" enctype="multipart/form-data" class="mig-confirm">
+            <p class="form-hint"><%= t('mig.r_confirm_hint') %></p>
+            <label for="mig-file2"><%= t('mig.file_label') %></label>
+            <input type="file" id="mig-file2" name="archief" accept=".zip,application/zip" required>
+            <input type="hidden" name="echt" value="1">
+            <% if (verslag.overschrijf) { %><input type="hidden" name="overschrijf" value="1"><% } %>
+            <button type="submit" class="btn btn-primary"><%= t('mig.r_confirm_btn') %></button>
+          </form>
+        <% } %>
+      </div>
+    <% } %>
+
+    <form method="post" action="/admin/migrate/import" enctype="multipart/form-data">
+      <label for="mig-file"><%= t('mig.file_label') %></label>
+      <input type="file" id="mig-file" name="archief" accept=".zip,application/zip" required>
+
+      <label class="mig-check">
+        <input type="checkbox" name="overschrijf" value="1">
+        <%= t('mig.overwrite_label') %>
+      </label>
+      <p class="form-hint mig-warn"><%= t('mig.overwrite_hint') %></p>
+
+      <button type="submit" class="btn"><%= t('mig.check_btn') %></button>
+      <p class="form-hint"><%= t('mig.check_hint') %></p>
+    </form>
+  </section>
+
+  <%# ── Wie je volgt ────────────────────────────────────────────── %>
+  <section class="mig-card">
+    <h2><%= t('mig.follows_title') %></h2>
+    <p class="form-hint"><%= t('mig.follows_hint') %></p>
+    <p><a class="btn" href="/connect"><%= t('mig.follows_btn') %></a></p>
+  </section>
+</div>
+
+<style>
+  .mig-wrap { max-width: 46rem; }
+  .mig-lead { color: var(--sub); margin-bottom: 1.5rem; }
+  .mig-card { border: 1px solid var(--rule); border-radius: 12px; padding: 1.25rem 1.4rem; margin-bottom: 1.25rem; }
+  .mig-card h2 { margin: 0 0 .35rem; font-size: 1.1rem; }
+  .mig-counts { list-style: none; padding: 0; margin: .9rem 0; display: flex; flex-wrap: wrap; gap: .4rem 1.4rem; }
+  .mig-counts li { color: var(--sub); font-size: .94rem; }
+  .mig-counts strong { color: var(--ink); font-size: 1.05rem; }
+  .mig-card label { display: block; margin: .9rem 0 .35rem; font-weight: 600; font-size: .92rem; }
+  .mig-card input[type="file"] { display: block; margin-bottom: .6rem; }
+  .mig-check { display: flex; align-items: center; gap: .5rem; font-weight: 400 !important; }
+  .mig-input { width: 100%; max-width: 34rem; padding: .5rem .6rem; border: 1px solid var(--rule);
+               border-radius: 8px; background: transparent; color: var(--ink); font-size: .95rem; }
+  .mig-state { margin-top: .9rem; }
+  .mig-report code { word-break: break-all; font-size: .88rem; }
+  .mig-warn { color: var(--accent); }
+  .mig-report { border-left: 3px solid var(--accent); padding: .8rem 1rem; margin-bottom: 1.2rem;
+                background: color-mix(in srgb, var(--ink, #000) 4%, transparent); border-radius: 0 8px 8px 0; }
+  .mig-report.is-done { border-left-color: #3fa843; }
+  .mig-report h3 { margin: 0 0 .3rem; font-size: 1rem; }
+  .mig-warnlist { margin: .5rem 0; padding-left: 1.2rem; color: var(--accent); font-size: .9rem; }
+  .mig-confirm { margin-top: .9rem; padding-top: .9rem; border-top: 1px solid var(--rule); }
+</style>
Index: src/views/pages/admin.ejs
===================================================================
--- src/views/pages/admin.ejs	(revision 4101c89af6cbf06b37697e9fdb6ddfd13a53af86)
+++ src/views/pages/admin.ejs	(revision fbfd7a199340cd773cd153df2388d88f2084eea2)
@@ -32,4 +32,5 @@
       <% } %>
       <% if (primarySite) { %><a href="/admin/seo" class="btn"><%= t('admin.b_seo') %></a><% } %>
+      <% if (primarySite) { %><a href="/admin/migrate" class="btn"><%= t('admin.b_migrate') %></a><% } %>
       <a href="/admin/settings" class="btn"><%= t('admin.b_settings') %></a>
     <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
Index: test/fep1580-migration.test.js
===================================================================
--- test/fep1580-migration.test.js	(revision fbfd7a199340cd773cd153df2388d88f2084eea2)
+++ test/fep1580-migration.test.js	(revision fbfd7a199340cd773cd153df2388d88f2084eea2)
@@ -0,0 +1,324 @@
+// FEP-1580: je objecten verhuizen mee met een Move.
+//
+// FEP-7628 verhuist je VOLGERS en zegt zelf dat de inhoud een ander probleem is.
+// Dit is dat probleem. Twee rollen, en ze horen bij elkaar: de BRON geeft de
+// nieuwe instantie zijn eigen kijkrechten, de DOELkant haalt op en publiceert
+// een vertaaltabel zodat derden hun verwijzingen kunnen bijwerken.
+//
+// Het hele vertrouwen hangt aan één ding: `moved_to` staat er alleen als
+// moveAccount() een terugverwijzing in alsoKnownAs zag. Dat is tweezijdig
+// bewijs, en daarom is er geen tweede mechanisme (geen token, geen code) nodig.
+// Deze tests leggen precies vast hoever die sleutel reikt, want een sleutel die
+// te ver reikt geeft je hele geschiedenis aan de verkeerde.
+import { test, beforeEach } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://nieuw.example';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+{
+  const stil = console.log;
+  console.log = () => {};
+  try { dbMod.initializeDatabase(); } finally { console.log = stil; }
+}
+const AP = await import('../src/services/ActivityPubService.js');
+const Mig = await import('../src/services/MigrationService.js');
+
+const BRON = 'https://oud.example/ap/users/robo';
+const IK = 'https://nieuw.example/ap/users/ik';
+
+function site({ aliases = [], movedTo = null } = {}) {
+  db.prepare('INSERT OR IGNORE INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+    .run('u1', 'u1', 'u1@test', 'x', 'god');
+  db.prepare('INSERT OR IGNORE INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)')
+    .run('s1', 'ik', 'Mijn site', 'u1');
+  db.prepare('UPDATE sites SET ap_aliases = ?, moved_to = ? WHERE slug = ?')
+    .run(JSON.stringify(aliases), movedTo, 'ik');
+  return db.prepare('SELECT * FROM sites WHERE slug = ?').get('ik');
+}
+
+async function stil(fn) {
+  const w = console.warn; const l = console.log;
+  console.warn = () => {}; console.log = () => {};
+  try { return await fn(); } finally { console.warn = w; console.log = l; }
+}
+
+beforeEach(() => {
+  for (const t of ['ap_migration', 'ap_moves', 'ap_blocks', 'posts', 'ap_followers']) {
+    try { db.prepare(`DELETE FROM ${t}`).run(); } catch { /* tabel bestaat niet in deze build */ }
+  }
+});
+
+// ── BRONKANT: hoever reikt de sleutel ─────────────────────────────
+
+test('isMoveTarget geldt alleen voor precies de actor waar we heen gingen', () => {
+  site({ movedTo: BRON });
+  assert.equal(AP.isMoveTarget('ik', BRON), true);
+  assert.equal(AP.isMoveTarget('ik', BRON + '2'), false, 'een prefix is geen match');
+  assert.equal(AP.isMoveTarget('ik', 'https://oud.example/ap/users/iemand'), false, 'zelfde host is niet genoeg');
+  assert.equal(AP.isMoveTarget('ik', ''), false);
+  assert.equal(AP.isMoveTarget('ik', null), false);
+});
+
+test('zonder verhuizing opent de sleutel niets', () => {
+  site({ movedTo: null });
+  assert.equal(AP.isMoveTarget('ik', BRON), false);
+  assert.equal(AP.outboxAudience('ik', { verifiedActor: BRON }), 'public');
+});
+
+test('de doelinstantie krijgt de fan-only geschiedenis te zien', () => {
+  // Dit is de kern van de bronkant. Zonder deze tak haalt de nieuwe Klonkt
+  // alleen je publieke berichten op en blijft de rest achter op een domein
+  // dat je gaat opzeggen.
+  site({ movedTo: BRON });
+  assert.equal(AP.outboxAudience('ik', { verifiedActor: BRON }), 'friend');
+  const post = { fan_only: 1 };
+  assert.equal(AP.mayReadNote({ slug: 'ik' }, post, BRON), true);
+  assert.equal(AP.mayReadNote({ slug: 'ik' }, post, 'https://elders.example/users/x'), false,
+    'een vreemde blijft buiten, ook tijdens een verhuizing');
+});
+
+test('een geblokkeerde actor wint van de verhuizing', () => {
+  // De volgorde in outboxAudience is niet toevallig: een block is een gesloten
+  // deur, en die gaat niet open omdat er toevallig een verhuizing loopt. Zou
+  // iemand ooit moved_to naar een geblokkeerd account zetten, dan hoort de
+  // blokkade te winnen en niet andersom.
+  site({ movedTo: BRON });
+  db.prepare("INSERT INTO ap_blocks (slug, target, kind) VALUES (?, ?, 'actor')").run('ik', BRON);
+  assert.equal(AP.outboxAudience('ik', { verifiedActor: BRON }), 'blocked');
+});
+
+test('direct-berichten gaan nooit over de lijn, ook niet bij een verhuizing', () => {
+  site({ movedTo: BRON });
+  const dm = { ap_visibility: 'direct' };
+  assert.equal(AP.mayReadNote({ slug: 'ik' }, dm, BRON), false,
+    'een DM is aan iemand gericht; die migreer je niet via een GET');
+});
+
+test('de outbox adresseert een fan-only post NIET als publiek', () => {
+  // Gevonden tijdens de end-to-end test van deze feature, maar het is een
+  // bestaande fout die er los van staat: outboxSlice haalde fan_only en
+  // ap_visibility niet op, dus buildNote zag post.fan_only === undefined en
+  // zette to: as:Public op ALLES. De post werd wel alleen aan vrienden
+  // geserveerd, maar met een publiek etiket erop, en dan mag een volger hem
+  // publiek boosten.
+  //
+  // Deze test hangt hier omdat de migratie erop leunt: de doelkant beslist aan
+  // de hand van dit adres of een bericht in de PUBLIEKE vertaaltabel mag.
+  const s = site();
+  db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status, fan_only, published_at)
+              VALUES ('pf', 's1', 'u1', 'fans', 'Fans', '<p>x</p>', 'published', 1, '2024-01-01T00:00:00Z')`).run();
+  db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status, published_at)
+              VALUES ('pp', 's1', 'u1', 'open', 'Open', '<p>y</p>', 'published', '2024-01-02T00:00:00Z')`).run();
+
+  const { posts } = AP.outboxSlice('s1', { fanOnly: true, page: 1 });
+  const rij = (id) => posts.find((p) => p.id === id);
+  assert.ok(rij('pf'), 'de fan-only post hoort in de vrienden-outbox te zitten');
+  assert.equal(rij('pf').fan_only, 1, 'en zijn zichtbaarheid moet MEE uit de database komen');
+
+  const fan = AP.buildNote('https://nieuw.example', s, rij('pf'));
+  assert.equal(AP.noteVisibility(fan), 'followers',
+    'een fan-only post die as:Public heet mag een volger publiek boosten');
+  const open = AP.buildNote('https://nieuw.example', s, rij('pp'));
+  assert.equal(AP.noteVisibility(open), 'public', 'en een gewone post blijft gewoon publiek');
+});
+
+// ── DOELKANT: de collecties ───────────────────────────────────────
+
+test('de actor adverteert migration en moves, ook als er niets verhuisd is', () => {
+  // De FEP wijst hier apart op: zonder deze twee is "een verhuizing zonder
+  // objecten" niet te onderscheiden van "een server die dit niet kent".
+  const doc = AP.buildActor('https://nieuw.example', site());
+  assert.equal(doc.migration, `${IK}/migration`);
+  assert.equal(doc.moves, `${IK}/moves`);
+});
+
+test('een lege migration-collectie is geldig en zegt dat hij klaar is', () => {
+  const coll = Mig.buildMigration('https://nieuw.example', site());
+  assert.equal(coll.type, 'OrderedCollection');
+  assert.equal(coll.totalItems, 0);
+  assert.equal(coll.migrationComplete, true, 'niets te doen is ook klaar; anders blijven derden pollen');
+  assert.equal(coll.moves, `${IK}/moves`, 'de spec eist de verwijzing naar de moves-collectie');
+});
+
+test('de vertaaltabel mapt oud naar nieuw, nieuwste kopie eerst', () => {
+  const s = site();
+  Mig.recordMigrated('ik', { origin: `${BRON}/notes/1`, target: `${IK}/notes/a`, sourceActor: BRON });
+  Mig.recordMigrated('ik', { origin: `${BRON}/notes/2`, target: `${IK}/notes/b`, sourceActor: BRON });
+  const coll = Mig.buildMigration('https://nieuw.example', s);
+  assert.equal(coll.totalItems, 2);
+  const items = coll.orderedItems || coll.items;
+  assert.equal(items[0].origin, `${BRON}/notes/2`, 'omgekeerd chronologisch op aanmaakmoment HIER');
+  assert.equal(items[0].type, 'Move');
+  assert.equal(items[0].target, `${IK}/notes/b`);
+});
+
+test('niet-publieke items staan niet in de publieke vertaaltabel', () => {
+  // Een lijst met de URIs van je fan-only posts is een lek, ook zonder inhoud:
+  // hij verraadt hoeveel er zijn en wanneer ze kwamen.
+  const s = site();
+  Mig.recordMigrated('ik', { origin: `${BRON}/notes/pub`, target: `${IK}/notes/a`, isPublic: true });
+  Mig.recordMigrated('ik', { origin: `${BRON}/notes/geheim`, target: `${IK}/notes/b`, isPublic: false });
+  assert.equal(Mig.buildMigration('https://nieuw.example', s).totalItems, 1);
+  assert.equal(Mig.buildMigration('https://nieuw.example', s, { alles: true }).totalItems, 2);
+  const publiek = JSON.stringify(Mig.buildMigration('https://nieuw.example', s));
+  assert.ok(!publiek.includes('geheim'), 'de URI zelf mag er ook niet in staan');
+});
+
+test('dezelfde origin twee keer levert een rij, geen dubbele', () => {
+  const s = site();
+  Mig.recordMigrated('ik', { origin: `${BRON}/notes/1`, target: `${IK}/notes/a` });
+  Mig.recordMigrated('ik', { origin: `${BRON}/notes/1`, target: `${IK}/notes/a` });
+  assert.equal(Mig.buildMigration('https://nieuw.example', s).totalItems, 1,
+    'een tweede ingest-ronde mag de tabel niet verdubbelen');
+});
+
+test('de moves-collectie bewaart het bron-actordocument erbij', () => {
+  // De spec: sluit de bron-actor in, zodat een lezer de herkomst kan nakijken
+  // ook als de bron onbereikbaar is. Dat is precies het geval waarvoor dit
+  // bestaat, dus een verwijzing naar de bron zou hier niets waard zijn.
+  const s = site();
+  const actorDoc = { id: BRON, type: 'Person', publicKey: { id: `${BRON}#main-key`, owner: BRON, publicKeyPem: 'x' } };
+  Mig.recordMove('ik', { moveId: `${BRON}#move`, sourceActor: BRON, targetActor: IK, activity: { type: 'Move' }, actorDoc });
+  const coll = Mig.buildMoves('https://nieuw.example', s);
+  assert.equal(coll.totalItems, 1);
+  assert.equal(coll.orderedItems[0].origin, BRON);
+  assert.equal(coll.orderedItems[0].target, IK);
+  assert.equal(coll.orderedItems[0].actor.publicKey.publicKeyPem, 'x', 'ingesloten, niet als verwijzing');
+});
+
+test('er staat GEEN handtekening onder de moves-collectie', () => {
+  // Bewuste keuze zolang shaer-j1v0 (FEP-8b32) open staat. Een leeg of nep
+  // proof-veld is erger dan geen veld: een derde die het controleert wordt dan
+  // misleid. Deze test valt om zodra we 8b32 bouwen, en dat is de bedoeling.
+  const s = site();
+  Mig.recordMove('ik', { moveId: `${BRON}#move`, sourceActor: BRON, targetActor: IK, activity: {} });
+  const coll = Mig.buildMoves('https://nieuw.example', s);
+  assert.equal(coll.proof, undefined,
+    'liever eerlijk niets dan een proof-veld dat niets bewijst (zie shaer-j1v0)');
+});
+
+// ── DOELKANT: de ingest ───────────────────────────────────────────
+
+// Een nep-bron, zodat dit zonder netwerk draait.
+function bronnetje({ movedTo = IK, items = [], blocked = null } = {}) {
+  const actor = {
+    id: BRON, type: 'Person', movedTo,
+    outbox: `${BRON}/outbox`,
+    ...(blocked ? { blocked: `${BRON}/blocked` } : {}),
+  };
+  const kaart = new Map([
+    [BRON, actor],
+    [`${BRON}/outbox`, { type: 'OrderedCollection', orderedItems: items }],
+    ...(blocked ? [[`${BRON}/blocked`, { type: 'OrderedCollection', orderedItems: blocked }]] : []),
+  ]);
+  // noteVisibility erbij: zonder deze telt alles als niet-publiek (fail-closed),
+  // en dan zou de test op de publieke vertaaltabel groen zijn om de verkeerde reden.
+  return {
+    getJson: async (_slug, url) => kaart.get(url) || null,
+    noteId: (b, id) => `${b}/ap/notes/${id}`,
+    noteVisibility: AP.noteVisibility,
+  };
+}
+
+function note(id, extra = {}) {
+  return {
+    type: 'Create',
+    object: {
+      id, type: 'Note', attributedTo: BRON, content: '<p>hallo</p>',
+      published: '2023-05-04T10:00:00Z', to: ['https://www.w3.org/ns/activitystreams#Public'],
+      ...extra,
+    },
+  };
+}
+
+test('de ingest weigert als de bron niet naar ONS wijst', async () => {
+  const s = site({ aliases: [BRON] });
+  const r = await stil(() => Mig.ingestFromSource(s, { deps: bronnetje({ movedTo: 'https://iemandanders.example/users/x' }) }));
+  assert.equal(r.error, 'not_moved_here',
+    'anders kon je de geschiedenis opeisen van iedereen die toevallig verhuisde');
+});
+
+test('de ingest weigert zonder onze eigen terugverwijzing', async () => {
+  // Eén kant is een bewering, twee kanten is een afspraak. Dit is de aanval:
+  // iemand laat je een bron-adres invullen, die bron roept movedTo naar ons,
+  // en zonder deze controle trekken we andermans geschiedenis binnen als de
+  // onze. De bron wordt hier EXPLICIET meegegeven, want anders strandt hij al
+  // eerder op no_source en test je niets.
+  const s = site({ aliases: ['https://ietsanders.example/users/ik'] });
+  const r = await stil(() => Mig.ingestFromSource(s, { sourceUri: BRON, deps: bronnetje({}) }));
+  assert.equal(r.error, 'no_backreference');
+  assert.equal(db.prepare('SELECT COUNT(*) n FROM posts').get().n, 0, 'en er is niets binnengekomen');
+});
+
+test('zonder alias en zonder opgegeven bron valt er niets op te halen', async () => {
+  const s = site({ aliases: [] });
+  const r = await stil(() => Mig.ingestFromSource(s, { deps: bronnetje({}) }));
+  assert.equal(r.error, 'no_source');
+});
+
+test('de ingest haalt de berichten op en houdt de oorspronkelijke datum', async () => {
+  const s = site({ aliases: [BRON] });
+  const deps = bronnetje({ items: [note(`${BRON}/notes/1`), note(`${BRON}/notes/2`)] });
+  const r = await stil(() => Mig.ingestFromSource(s, { deps }));
+  assert.equal(r.posts, 2);
+  const rijen = db.prepare('SELECT slug, published_at, origin_server FROM posts WHERE site_id = ? ORDER BY slug').all('s1');
+  assert.equal(rijen.length, 2);
+  assert.equal(rijen[0].published_at, '2023-05-04T10:00:00Z',
+    'een verhuisd bericht is niet vandaag geschreven; de spec eist behoud van de timestamps');
+  assert.equal(rijen[0].origin_server, 'migrated');
+  // en de vertaaltabel is gevuld, want dat is het punt van de hele operatie
+  assert.equal(Mig.buildMigration('https://nieuw.example', s).totalItems, 2);
+});
+
+test('de ingest zet de vlag open tijdens en dicht na afloop', async () => {
+  const s = site({ aliases: [BRON] });
+  await stil(() => Mig.ingestFromSource(s, { deps: bronnetje({ items: [note(`${BRON}/notes/1`)] }) }));
+  assert.equal(Mig.migrationComplete('ik'), true, 'pas na afloop mag een derde stoppen met kijken');
+});
+
+test('een tweede ronde slaat over wat er al is', async () => {
+  const s = site({ aliases: [BRON] });
+  const deps = bronnetje({ items: [note(`${BRON}/notes/1`)] });
+  await stil(() => Mig.ingestFromSource(s, { deps }));
+  const r = await stil(() => Mig.ingestFromSource(s, { deps }));
+  assert.equal(r.posts, 0);
+  assert.equal(r.overgeslagen, 1);
+  assert.equal(db.prepare('SELECT COUNT(*) n FROM posts').get().n, 1, 'geen dubbele berichten');
+});
+
+test('blokkades komen eerst binnen', async () => {
+  // De spec zet dit expliciet vooraan: blokkades bepalen wie de rest te zien
+  // krijgt. Andersom importeer je je hele geschiedenis zichtbaar voor precies
+  // degene die je buiten wilde houden.
+  const s = site({ aliases: [BRON] });
+  const deps = bronnetje({ items: [note(`${BRON}/notes/1`)], blocked: ['https://elders.example/users/pest'] });
+  const r = await stil(() => Mig.ingestFromSource(s, { deps }));
+  assert.equal(r.blocks, 1);
+  const b = db.prepare("SELECT target FROM ap_blocks WHERE slug = 'ik' AND kind = 'actor'").all();
+  assert.deepEqual(b.map((x) => x.target), ['https://elders.example/users/pest']);
+});
+
+test('de ingest neemt geen antwoorden en niets van een ander mee', async () => {
+  const s = site({ aliases: [BRON] });
+  const deps = bronnetje({
+    items: [
+      note(`${BRON}/notes/1`),
+      note(`${BRON}/notes/2`, { inReplyTo: 'https://elders.example/notes/9' }),
+      note(`${BRON}/notes/3`, { attributedTo: 'https://elders.example/users/ander' }),
+    ],
+  });
+  const r = await stil(() => Mig.ingestFromSource(s, { deps }));
+  assert.equal(r.posts, 1, 'alleen je eigen toplevel-berichten verhuizen mee');
+});
+
+test('een niet-publiek bericht komt wel mee maar niet in de publieke tabel', async () => {
+  const s = site({ aliases: [BRON] });
+  const deps = bronnetje({ items: [note(`${BRON}/notes/priv`, { to: [`${BRON}/followers`] })] });
+  const r = await stil(() => Mig.ingestFromSource(s, { deps }));
+  assert.equal(r.posts, 1);
+  assert.equal(Mig.buildMigration('https://nieuw.example', s).totalItems, 0);
+  assert.equal(Mig.buildMigration('https://nieuw.example', s, { alles: true }).totalItems, 1);
+});
