Index: test/archive-export.test.js
===================================================================
--- test/archive-export.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ test/archive-export.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,227 @@
+// De exporter (shaer-1a6), getoetst aan het formaat in docs/EXPORT-FORMAT.md.
+//
+// De acceptatietest uit dat document is de MOEILIJKSTE post die Klonkt kan
+// maken, niet de makkelijkste: een poll met bijlagen, een quote-kaart, een
+// content warning en betaalde toegang. Die staat hieronder als eerste.
+//
+// Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import crypto from 'crypto';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+
+// De media-map moet bestaan voordat paths.js hem uitleest.
+const MEDIA = fs.mkdtempSync(path.join(os.tmpdir(), 'klonkt-media-'));
+process.env.MEDIA_PATH = MEDIA;
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AX = await import('../src/services/ArchiveExportService.js');
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'u1', 'u1@test', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Mijn site', 'u1');
+
+fs.mkdirSync(path.join(MEDIA, '2026'), { recursive: true });
+const PLAATJE = Buffer.from('dit-zijn-de-bytes-van-een-plaatje');
+fs.writeFileSync(path.join(MEDIA, '2026', 'cover.jpg'), PLAATJE);
+const PLAATJE_HASH = crypto.createHash('sha256').update(PLAATJE).digest('hex');
+
+function post(id, extra = {}) {
+  const kol = {
+    id, site_id: 's1', slug: id, author_id: 'u1', title: 'Titel', content: '<p>inhoud</p>',
+    status: 'published', published_at: '2026-08-01 10:00:00', created_at: '2026-08-01 10:00:00',
+    updated_at: '2026-08-01 10:00:00', ...extra,
+  };
+  const namen = Object.keys(kol);
+  db.prepare(`INSERT INTO posts (${namen.join(',')}) VALUES (${namen.map(() => '?').join(',')})`)
+    .run(...namen.map((n) => kol[n]));
+}
+
+const lees = (uit, pad) => JSON.parse(uit.files.get(pad).toString('utf8'));
+
+test('de moeilijkste post overleeft de export heel', () => {
+  post('zwaar', {
+    title: 'Een zware post',
+    content: '<p>body</p><img src="/media/2026/cover.jpg" alt="cover">',
+    content_warning: 'spoiler', nsfw: 1, paid: 1, paid_min_cents: 500,
+    quote_uri: 'https://mstdn.social/users/x/statuses/1', quote_actor: 'https://mstdn.social/users/x',
+    poll_json: JSON.stringify({ multiple: false, options: [{ name: 'ja' }, { name: 'nee' }], endTime: '2026-08-09T10:00:00Z' }),
+    language: 'nl', tags: 'muziek, code',
+  });
+  const uit = AX.buildArchive('me');
+  const o = lees(uit, 'posts/zwaar.json');
+
+  assert.equal(o.type, 'Question', 'een poll exporteert als Question');
+  assert.deepEqual(o.oneOf.map((x) => x.name), ['ja', 'nee']);
+  assert.equal(o.endTime, '2026-08-09T10:00:00Z');
+  assert.equal(o.summary, 'spoiler', 'de content warning is de AS2 summary');
+  assert.equal(o.sensitive, true);
+  assert.equal(o.quoteUrl, 'https://mstdn.social/users/x/statuses/1');
+  assert.equal(o['shaer:quoteActor'], 'https://mstdn.social/users/x');
+  assert.equal(o['shaer:paidMinCents'], 500);
+  assert.deepEqual(o.contentMap, { nl: '<p>body</p><img src="/media/2026/cover.jpg" alt="cover">' });
+  assert.deepEqual(o.tag.map((t) => t.name), ['#muziek', '#code']);
+});
+
+test('een betaalde post exporteert de VOLLEDIGE inhoud, niet de teaser', () => {
+  // buildNote() federeert bewust alleen een teaser. Zou de export die projectie
+  // volgen, dan archiveer je je eigen betaalde post als samenvatting.
+  const o = lees(AX.buildArchive('me'), 'posts/zwaar.json');
+  assert.match(o.content, /<p>body<\/p>/);
+  assert.equal(o['shaer:paid'], true);
+});
+
+test('de titel staat in name, niet in de content gebakken', () => {
+  // Ook een verschil met de federatie-projectie: Mastodon negeert `name`, dus
+  // daar gaat de titel de content in. Een archief hoort dat niet over te nemen.
+  const o = lees(AX.buildArchive('me'), 'posts/zwaar.json');
+  assert.equal(o.name, 'Een zware post');
+  assert.equal(o.type, 'Question');
+  assert.doesNotMatch(o.content, /<strong>Een zware post<\/strong>/);
+});
+
+test('media reizen mee als bytes, gesleuteld op hun inhoud', () => {
+  const uit = AX.buildArchive('me');
+  const o = lees(uit, 'posts/zwaar.json');
+  const a = o.attachment[0];
+  assert.equal(a['shaer:availability'], 'included');
+  assert.equal(a['shaer:sha256'], PLAATJE_HASH);
+  assert.equal(a.url, `media/${PLAATJE_HASH}.jpg`, 'de verwijzing is container-relatief');
+  assert.equal(a['shaer:originalUrl'], 'https://klonkt.test/media/2026/cover.jpg');
+  assert.deepEqual(uit.files.get(a.url), PLAATJE, 'en de bytes zitten er echt in');
+});
+
+test('een verdwenen bestand wordt gemeld, niet weggelaten', () => {
+  // De derde mediastaat. Een lege medialijst zou een leugen zijn en een
+  // verwijzing naar een ontbrekend bestand een kapot archief.
+  post('kwijt', { content: '<p>x</p><img src="/media/2026/weg.jpg">' });
+  const uit = AX.buildArchive('me');
+  const a = lees(uit, 'posts/kwijt.json').attachment[0];
+  assert.equal(a['shaer:availability'], 'missing');
+  assert.equal(a.url, 'https://klonkt.test/media/2026/weg.jpg', 'de oorspronkelijke plek blijft staan');
+  assert.equal(uit.counts.mediaMissing, 1);
+  assert.equal(uit.missing[0].post, 'kwijt');
+});
+
+test('een pad buiten de media-map wordt niet gevolgd', () => {
+  // Zonder deze grens trekt een verzonnen pad in oude inhoud een willekeurig
+  // bestand van de schijf het archief in.
+  post('sluipweg', { content: '<p>x</p><img src="/media/../../../etc/passwd">' });
+  const uit = AX.buildArchive('me');
+  const a = lees(uit, 'posts/sluipweg.json').attachment[0];
+  assert.equal(a['shaer:availability'], 'missing');
+});
+
+test('antwoorden van anderen staan apart en zijn gemarkeerd als archief', () => {
+  db.prepare(`INSERT INTO ap_interactions (kind, post_id, object_uri, actor_uri, actor_name, content, created_at)
+              VALUES ('reply', 'zwaar', ?, ?, 'Anna', '<p>hoi</p>', '2026-08-02 10:00:00')`)
+    .run('https://mstdn.social/users/anna/statuses/9', 'https://mstdn.social/users/anna');
+  const uit = AX.buildArchive('me');
+  const c = lees(uit, 'replies/zwaar.json');
+  assert.equal(c['shaer:archive'], true, 'hierop keert een importer: nooit opnieuw bezorgen');
+  assert.equal(c.totalItems, 1);
+  assert.equal(c.orderedItems[0].id, 'https://mstdn.social/users/anna/statuses/9');
+  assert.equal(uit.counts.replies, 1);
+});
+
+test('een gehoste track reist mee, met de gegevens die alleen in de database staan', () => {
+  // [[track:]] verwijst naar een audio_tracks-rij met een media-rij eronder. Op
+  // beta staan nul tracks, dus dit pad raakt daar geen echte data -- vandaar hier.
+  fs.mkdirSync(path.join(MEDIA, 'audio'), { recursive: true });
+  const WAV = Buffer.from('RIFF-nep-audio');
+  fs.writeFileSync(path.join(MEDIA, 'audio', 't1.mp3'), WAV);
+  db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
+    .run('m1', 's1', 't1.mp3', 'audio/mpeg', WAV.length, path.join(MEDIA, 'audio', 't1.mp3'));
+  db.prepare(`INSERT INTO audio_tracks (id, site_id, title, artist, duration, media_id, credit, license, link_spotify)
+              VALUES ('tr1','s1','Kanonnen','Robin',212,'m1','opname 2026','CC BY','https://open.spotify.com/track/x')`).run();
+  post('metaudio', { content: '<p>luister</p>[[track:tr1]]' });
+
+  const uit = AX.buildArchive('me');
+  const o = lees(uit, 'posts/metaudio.json');
+  const a = o.attachment[0];
+  assert.equal(a['shaer:availability'], 'included');
+  assert.equal(a.mediaType, 'audio/mpeg');
+  assert.deepEqual(uit.files.get(a.url), WAV);
+  assert.match(o.content, /\[\[track:tr1\]\]/, 'de shorthand blijft in de bron staan');
+  const t = o['shaer:audio'][0];
+  assert.equal(t.name, 'Kanonnen');
+  assert.equal(t.artist, 'Robin');
+  assert.equal(t.license, 'CC BY');
+  assert.deepEqual(t.url, ['https://open.spotify.com/track/x']);
+});
+
+test('de poster van een audio-bijlage gaat mee', () => {
+  // Gevonden bij de export van echte beta-data: c2s_attachments draagt naast url
+  // ook een poster, en die viel er stil uit.
+  fs.writeFileSync(path.join(MEDIA, '2026', 'opname.m4a'), Buffer.from('audio'));
+  fs.writeFileSync(path.join(MEDIA, '2026', 'opname.m4a.poster.webp'), Buffer.from('poster'));
+  post('metposter', {
+    c2s_attachments: JSON.stringify([{
+      url: '/media/2026/opname.m4a', mediaType: 'audio/mp4', name: 'opname.m4a',
+      poster: '/media/2026/opname.m4a.poster.webp',
+    }]),
+  });
+  const o = lees(AX.buildArchive('me'), 'posts/metposter.json');
+  assert.equal(o.attachment.length, 2, 'de opname en de poster');
+  assert.ok(o.attachment.every((a) => a['shaer:availability'] === 'included'));
+});
+
+test('een concept gaat gewoon mee', () => {
+  post('concept', { status: 'draft', published_at: null });
+  const o = lees(AX.buildArchive('me'), 'posts/concept.json');
+  assert.equal(o['shaer:status'], 'draft');
+});
+
+test('twee exports van ongewijzigde inhoud zijn gelijk, op exportedAt na', () => {
+  const a = AX.buildArchive('me', { exportedAt: 'X' });
+  const b = AX.buildArchive('me', { exportedAt: 'Y' });
+  assert.deepEqual([...a.files.keys()].sort(), [...b.files.keys()].sort());
+  for (const pad of a.files.keys()) {
+    if (pad === 'manifest.json') continue;
+    assert.deepEqual(a.files.get(pad), b.files.get(pad), `${pad} verschilt`);
+  }
+  assert.deepEqual(a.manifest.files, b.manifest.files, 'de checksum-kaart hoort gelijk te zijn');
+});
+
+test('en de zip die eruit komt ook', () => {
+  const a = AX.buildArchive('me', { exportedAt: 'X' });
+  const b = AX.buildArchive('me', { exportedAt: 'X' });
+  assert.deepEqual(AX.zipArchive(a.files), AX.zipArchive(b.files), 'zonder vaste mtime en volgorde is dit toeval');
+});
+
+test('de zip is door unzip te lezen', async () => {
+  // Een zelfgeschreven zip die alleen zichzelf kan lezen is geen uitwisselformaat.
+  const uit = AX.buildArchive('me');
+  const zip = path.join(MEDIA, 'test.zip');
+  fs.writeFileSync(zip, AX.zipArchive(uit.files));
+  const { execFileSync } = await import('child_process');
+  let lijst;
+  try { lijst = execFileSync('unzip', ['-l', zip], { encoding: 'utf8' }); }
+  catch (e) { if (e.code === 'ENOENT') return; throw e; }   // geen unzip op deze machine
+  assert.match(lijst, /manifest\.json/);
+  assert.match(lijst, /posts\/zwaar\.json/);
+});
+
+test('het manifest draagt de origin, want daar hangt het behoud van AP-ids aan', () => {
+  const m = AX.buildArchive('me').manifest;
+  assert.equal(m.origin, 'https://klonkt.test');
+  assert.equal(m.formatVersion, 1);
+  assert.equal(m.actor, 'https://klonkt.test/ap/users/me');
+});
+
+test('posts van een ANDERE site komen er niet in', () => {
+  db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s2', 'buur', 'Buur', 'u1');
+  db.prepare('INSERT INTO posts (id, site_id, slug, author_id, title, content, status) VALUES (?,?,?,?,?,?,?)')
+    .run('vanbuur', 's2', 'vanbuur', 'u1', 'Niet van mij', '<p>x</p>', 'published');
+  assert.equal(AX.buildArchive('me').files.has('posts/vanbuur.json'), false);
+});
+
+test.after(() => { try { fs.rmSync(MEDIA, { recursive: true, force: true }); } catch { /* niets */ } });
Index: test/archive-import.test.js
===================================================================
--- test/archive-import.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ test/archive-import.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,344 @@
+// De importer (shaer-pmr), getoetst aan het formaat in docs/EXPORT-FORMAT.md.
+//
+// De zwaarste toets is de RONDGANG: exporteer de moeilijkste post die Klonkt kan
+// maken, gooi hem weg, lees hem terug, en kijk of er nog hetzelfde staat. Een
+// exporter en een importer die elk apart kloppen maar niet op elkaar aansluiten
+// zijn samen niets waard.
+//
+// Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+const MEDIA = fs.mkdtempSync(path.join(os.tmpdir(), 'klonkt-imp-'));
+process.env.MEDIA_PATH = MEDIA;
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AX = await import('../src/services/ArchiveExportService.js');
+const AI = await import('../src/services/ArchiveImportService.js');
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'u1', 'u1@test', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Mijn site', 'u1');
+
+fs.mkdirSync(path.join(MEDIA, '2026'), { recursive: true });
+const PLAATJE = Buffer.from('bytes-van-de-cover');
+fs.writeFileSync(path.join(MEDIA, '2026', 'cover.jpg'), PLAATJE);
+
+db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, excerpt, status, published_at,
+            created_at, updated_at, content_warning, nsfw, paid, paid_min_cents, quote_uri, quote_actor,
+            poll_json, language, tags, pinned, view_count)
+            VALUES ('zwaar','s1','zware-post','u1','Een zware post',
+            '<p>body</p><img src="/media/2026/cover.jpg" alt="cover">','de teaser','published',
+            '2026-08-01 10:00:00','2026-08-01 10:00:00','2026-08-01 12:00:00','spoiler',1,1,500,
+            'https://mstdn.social/users/x/statuses/1','https://mstdn.social/users/x',
+            '{"multiple":false,"options":[{"name":"ja"},{"name":"nee"}],"endTime":"2026-08-09T10:00:00Z"}',
+            'nl','muziek, code',1,42)`).run();
+db.prepare(`INSERT INTO ap_interactions (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, content, published, created_at)
+            VALUES ('reply','zwaar',?,?,'Anna','@anna@mstdn.social','<p>hoi</p>','2026-08-02T10:00:00Z','2026-08-02 10:00:00')`)
+  .run('https://mstdn.social/users/anna/statuses/9', 'https://mstdn.social/users/anna');
+
+const ARCHIEF = AX.buildArchive('me', { exportedAt: 'X' });
+const leeg = () => {
+  db.prepare('DELETE FROM posts').run();
+  db.prepare('DELETE FROM ap_interactions').run();
+  try { fs.rmSync(path.join(MEDIA, '2026'), { recursive: true, force: true }); } catch { /* niets */ }
+};
+
+test('de rondgang: wat eruit ging komt er hetzelfde weer in', () => {
+  leeg();
+  const r = AI.importArchive(ARCHIEF.files, { slug: 'me' });
+  assert.equal(r.posts, 1);
+  assert.equal(r.idsBehouden, true, 'zelfde origin, dus de AP-ids blijven');
+
+  const p = db.prepare('SELECT * FROM posts WHERE id = ?').get('zwaar');
+  assert.ok(p, 'het post-id is behouden -- daar wijzen de boosts elders naar');
+  assert.equal(p.title, 'Een zware post');
+  assert.equal(p.slug, 'zware-post');
+  assert.match(p.content, /<p>body<\/p>/);
+  assert.equal(p.excerpt, 'de teaser');
+  assert.equal(p.content_warning, 'spoiler');
+  assert.equal(p.nsfw, 1);
+  assert.equal(p.paid, 1);
+  assert.equal(p.paid_min_cents, 500);
+  assert.equal(p.quote_uri, 'https://mstdn.social/users/x/statuses/1');
+  assert.equal(p.language, 'nl');
+  assert.equal(p.tags, 'muziek, code');
+  assert.equal(p.pinned, 1);
+  assert.equal(p.view_count, 42);
+  assert.deepEqual(JSON.parse(p.poll_json).options.map((o) => o.name), ['ja', 'nee']);
+});
+
+test('en de media staan terug op hun oude plek, want de content wijst daarheen', () => {
+  const op = path.join(MEDIA, '2026', 'cover.jpg');
+  assert.ok(fs.existsSync(op), 'anders is de <img> in de body een dode verwijzing');
+  assert.deepEqual(fs.readFileSync(op), PLAATJE);
+});
+
+test('de antwoorden komen terug als archief', () => {
+  const r = db.prepare("SELECT * FROM ap_interactions WHERE post_id = 'zwaar'").all();
+  assert.equal(r.length, 1);
+  assert.equal(r[0].actor_name, 'Anna');
+  assert.equal(r[0].object_uri, 'https://mstdn.social/users/anna/statuses/9');
+});
+
+test('twee keer importeren levert geen dubbele posts op', () => {
+  const voor = db.prepare('SELECT COUNT(*) AS n FROM posts').get().n;
+  const r = AI.importArchive(ARCHIEF.files, { slug: 'me' });
+  assert.equal(r.overgeslagen, 1, 'de bestaande post wordt overgeslagen, niet overschreven');
+  assert.equal(r.posts, 0);
+  assert.equal(db.prepare('SELECT COUNT(*) AS n FROM posts').get().n, voor);
+});
+
+test('overschrijven gebeurt alleen als je erom vraagt', () => {
+  db.prepare("UPDATE posts SET title = 'handmatig gewijzigd' WHERE id = 'zwaar'").run();
+  AI.importArchive(ARCHIEF.files, { slug: 'me' });
+  assert.equal(db.prepare("SELECT title FROM posts WHERE id = 'zwaar'").get().title, 'handmatig gewijzigd',
+    'zonder --overwrite blijft wat er staat staan');
+  const r = AI.importArchive(ARCHIEF.files, { slug: 'me', overwrite: true });
+  assert.equal(r.overschreven, 1);
+  assert.equal(db.prepare("SELECT title FROM posts WHERE id = 'zwaar'").get().title, 'Een zware post');
+});
+
+test('een droogloop schrijft niets', () => {
+  leeg();
+  const r = AI.importArchive(ARCHIEF.files, { slug: 'me', dryRun: true });
+  assert.equal(r.posts, 1, 'hij vertelt wel wat er zou gebeuren');
+  assert.equal(db.prepare('SELECT COUNT(*) AS n FROM posts').get().n, 0);
+  assert.equal(fs.existsSync(path.join(MEDIA, '2026', 'cover.jpg')), false);
+});
+
+test('een nieuwere formaatversie wordt in zijn GEHEEL geweigerd', () => {
+  // Niet half lezen: een half begrepen herstel ziet eruit alsof het gelukt is.
+  const files = new Map(ARCHIEF.files);
+  const m = JSON.parse(files.get('manifest.json').toString());
+  m.formatVersion = 99;
+  files.set('manifest.json', Buffer.from(JSON.stringify(m)));
+  assert.throws(() => AI.importArchive(files, { slug: 'me' }), /nieuwer dan deze Klonkt kent/);
+  assert.equal(db.prepare('SELECT COUNT(*) AS n FROM posts').get().n, 0, 'en er is niets geschreven');
+});
+
+test('een andere origin levert NIEUWE ids op, met een waarschuwing', () => {
+  // Oude ids houden op een ander domein zou objecten publiceren onder een id dat
+  // je niet beheert -- andere servers halen dat daar op, en het is bovendien een
+  // vervalsingsoppervlak.
+  leeg();
+  const files = new Map(ARCHIEF.files);
+  const m = JSON.parse(files.get('manifest.json').toString());
+  m.origin = 'https://ergens-anders.test';
+  files.set('manifest.json', Buffer.from(JSON.stringify(m)));
+  const r = AI.importArchive(files, { slug: 'me' });
+  assert.equal(r.idsBehouden, false);
+  assert.match(r.waarschuwingen.join(' '), /origin verschilt/);
+  assert.equal(db.prepare("SELECT COUNT(*) AS n FROM posts WHERE id = 'zwaar'").get().n, 0);
+  assert.equal(db.prepare('SELECT COUNT(*) AS n FROM posts').get().n, 1, 'wel geimporteerd, met een nieuw id');
+});
+
+test('ontbrekende media worden geteld en gemeld', () => {
+  leeg();
+  const files = new Map(ARCHIEF.files);
+  const pad = [...files.keys()].find((k) => k.startsWith('posts/'));
+  const o = JSON.parse(files.get(pad).toString());
+  o.attachment = [{ type: 'Image', mediaType: 'image/jpeg', url: 'https://oud.test/media/weg.jpg', 'shaer:availability': 'missing', 'shaer:originalUrl': 'https://oud.test/media/weg.jpg' }];
+  files.set(pad, Buffer.from(JSON.stringify(o)));
+  const r = AI.importArchive(files, { slug: 'me' });
+  assert.equal(r.mediaMissing, 1);
+  assert.equal(r.gemist[0].url, 'https://oud.test/media/weg.jpg');
+});
+
+test('een archief dat zijn eigen bestand mist wordt apart gemeld', () => {
+  // 'included' met een ontbrekend bestand is een KAPOT archief, geen verdwenen
+  // media. Die twee op een hoop gooien verbergt een echt probleem.
+  leeg();
+  const files = new Map(ARCHIEF.files);
+  for (const k of [...files.keys()]) if (k.startsWith('media/')) files.delete(k);
+  const r = AI.importArchive(files, { slug: 'me' });
+  assert.equal(r.mediaMissing, 0);
+  assert.match(r.waarschuwingen.join(' '), /dat er niet in zit/);
+});
+
+test('een bestand met een verkeerde checksum wordt niet weggeschreven', () => {
+  leeg();
+  const files = new Map(ARCHIEF.files);
+  const k = [...files.keys()].find((x) => x.startsWith('media/'));
+  files.set(k, Buffer.from('iets heel anders'));
+  const r = AI.importArchive(files, { slug: 'me' });
+  assert.match(r.waarschuwingen.join(' '), /checksum klopt niet/);
+  assert.equal(fs.existsSync(path.join(MEDIA, '2026', 'cover.jpg')), false);
+});
+
+test('een antwoordenbundel zonder archiefmarkering wordt overgeslagen', () => {
+  leeg();
+  const files = new Map(ARCHIEF.files);
+  const pad = [...files.keys()].find((k) => k.startsWith('replies/'));
+  const c = JSON.parse(files.get(pad).toString());
+  delete c['shaer:archive'];
+  files.set(pad, Buffer.from(JSON.stringify(c)));
+  const r = AI.importArchive(files, { slug: 'me' });
+  assert.equal(r.replies, 0);
+  assert.match(r.waarschuwingen.join(' '), /niet gemarkeerd als archief/);
+});
+
+test('een pad buiten de media-map wordt niet geschreven', () => {
+  leeg();
+  const files = new Map(ARCHIEF.files);
+  const pad = [...files.keys()].find((k) => k.startsWith('posts/'));
+  const o = JSON.parse(files.get(pad).toString());
+  const mk = [...files.keys()].find((k) => k.startsWith('media/'));
+  o.attachment = [{ type: 'Image', mediaType: 'image/jpeg', url: mk, 'shaer:availability': 'included', 'shaer:originalUrl': '/media/../../../tmp/klonkt-inbraak' }];
+  files.set(pad, Buffer.from(JSON.stringify(o)));
+  AI.importArchive(files, { slug: 'me' });
+  assert.equal(fs.existsSync('/tmp/klonkt-inbraak'), false);
+});
+
+test('readable/ wordt nooit gelezen', () => {
+  // Dat is de hele reden dat het afgeleid is: twee bronnen die allebei gelezen
+  // worden gaan uiteenlopen.
+  leeg();
+  const files = new Map(ARCHIEF.files);
+  for (const k of [...files.keys()]) if (k.startsWith('readable/')) files.set(k, Buffer.from('ONZIN'));
+  const r = AI.importArchive(files, { slug: 'me' });
+  assert.equal(r.posts, 1);
+  assert.match(db.prepare("SELECT content FROM posts WHERE id = 'zwaar'").get().content, /<p>body<\/p>/);
+});
+
+test('de zip-lezer haalt hetzelfde eruit als de map-lezer', () => {
+  const zip = path.join(MEDIA, 'rond.zip');
+  fs.writeFileSync(zip, AX.zipArchive(ARCHIEF.files));
+  const uit = AI.readArchive(zip);
+  assert.deepEqual([...uit.keys()].sort(), [...ARCHIEF.files.keys()].sort());
+  for (const k of uit.keys()) assert.deepEqual(uit.get(k), ARCHIEF.files.get(k), `${k} verschilt`);
+});
+
+test('een GECOMPRIMEERDE zip van elders wordt ook gelezen', async () => {
+  // Onze eigen zip is store-only. Zou de lezer alleen dat aankunnen, dan is het
+  // geen uitwisselformaat maar een privé-doosje.
+  const zlib = await import('zlib');
+  const inhoud = Buffer.from('{"formatVersion":1,"origin":"x"}');
+  const naam = Buffer.from('manifest.json', 'utf8');
+  const comp = zlib.deflateRawSync(inhoud);
+  const crc = (() => {
+    let c = -1;
+    for (const b of inhoud) { c ^= b; for (let k = 0; k < 8; k++) c = c & 1 ? (c >>> 1) ^ 0xEDB88320 : c >>> 1; }
+    return (c ^ -1) >>> 0;
+  })();
+  const lh = Buffer.alloc(30);
+  lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt16LE(8, 8);
+  lh.writeUInt32LE(crc, 14); lh.writeUInt32LE(comp.length, 18); lh.writeUInt32LE(inhoud.length, 22);
+  lh.writeUInt16LE(naam.length, 26);
+  const ch = Buffer.alloc(46);
+  ch.writeUInt32LE(0x02014b50, 0); ch.writeUInt16LE(8, 10);
+  ch.writeUInt32LE(crc, 16); ch.writeUInt32LE(comp.length, 20); ch.writeUInt32LE(inhoud.length, 24);
+  ch.writeUInt16LE(naam.length, 28); ch.writeUInt32LE(0, 42);
+  const eocd = Buffer.alloc(22);
+  eocd.writeUInt32LE(0x06054b50, 0); eocd.writeUInt16LE(1, 8); eocd.writeUInt16LE(1, 10);
+  eocd.writeUInt32LE(46 + naam.length, 12); eocd.writeUInt32LE(30 + naam.length + comp.length, 16);
+  const zip = path.join(MEDIA, 'deflate.zip');
+  fs.writeFileSync(zip, Buffer.concat([lh, naam, comp, ch, naam, eocd]));
+  assert.deepEqual(AI.readArchiveZip(fs.readFileSync(zip)).get('manifest.json'), inhoud);
+});
+
+test('een gehoste track overleeft de rondgang, met zijn bestand erbij', () => {
+  leeg();
+  db.prepare('DELETE FROM audio_tracks').run();
+  fs.mkdirSync(path.join(MEDIA, 'audio'), { recursive: true });
+  const WAV = Buffer.from('nep-audio-bytes');
+  fs.writeFileSync(path.join(MEDIA, 'audio', 't1.mp3'), WAV);
+  db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
+    .run('m1', 's1', 't1.mp3', 'audio/mpeg', WAV.length, path.join(MEDIA, 'audio', 't1.mp3'));
+  db.prepare(`INSERT INTO audio_tracks (id, site_id, title, artist, duration, media_id, credit, license)
+              VALUES ('tr1','s1','Kanonnen','Robin',212,'m1','opname 2026','CC BY')`).run();
+  db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, status, published_at)
+              VALUES ('audio','s1','met-audio','u1','Met audio','<p>luister</p>[[track:tr1]]','published','2026-08-03 10:00:00')`).run();
+
+  const arch = AX.buildArchive('me', { exportedAt: 'X' });
+  db.prepare('DELETE FROM posts').run();
+  db.prepare('DELETE FROM audio_tracks').run();
+  db.prepare('DELETE FROM media').run();
+  fs.rmSync(path.join(MEDIA, 'audio'), { recursive: true, force: true });
+
+  AI.importArchive(arch.files, { slug: 'me' });
+  const t = db.prepare("SELECT * FROM audio_tracks WHERE id = 'tr1'").get();
+  assert.ok(t, '[[track:tr1]] in de content valt anders op niets terug');
+  assert.equal(t.title, 'Kanonnen');
+  assert.equal(t.license, 'CC BY');
+  const m = db.prepare('SELECT * FROM media WHERE id = ?').get(t.media_id);
+  assert.ok(m && fs.existsSync(m.storage_path), 'en het bestand staat er weer');
+  assert.deepEqual(fs.readFileSync(m.storage_path), WAV);
+});
+
+test('een vroegtijdig gesloten poll blijft gesloten', () => {
+  // Gevonden bij de rondgang op echte beta-data: poll_json.closed viel weg, dus
+  // een poll die je met een einddatum in de TOEKOMST had gesloten stond na een
+  // herstel weer open. AS2 kent hiervoor `closed` op een Question.
+  leeg();
+  db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, status, published_at, poll_json)
+              VALUES ('dicht','s1','dichte-poll','u1','Dicht','<p>x</p>','published','2026-08-04 10:00:00',
+              '{"multiple":false,"options":[{"name":"a"},{"name":"b"}],"endTime":"2030-01-01T00:00:00Z","closed":true}')`).run();
+  const arch = AX.buildArchive('me', { exportedAt: 'X' });
+  assert.equal(JSON.parse(arch.files.get('posts/dicht.json').toString()).closed, true);
+  db.prepare('DELETE FROM posts').run();
+  AI.importArchive(arch.files, { slug: 'me' });
+  const d = JSON.parse(db.prepare("SELECT poll_json FROM posts WHERE id = 'dicht'").get().poll_json);
+  assert.equal(d.closed, true, 'anders staat een gesloten stemming na een herstel weer open');
+  assert.equal(d.endTime, '2030-01-01T00:00:00Z');
+});
+
+test('de cover en de speler komen terug, niet alleen hun bestanden', () => {
+  // Gevonden bij het oefenherstel, door ALLE kolommen te vergelijken in plaats
+  // van een handjevol: de bytes zaten in het archief, maar nergens stond dat ze
+  // de cover waren. De post kwam zonder cover en zonder speler terug.
+  leeg();
+  fs.mkdirSync(path.join(MEDIA, 'c'), { recursive: true });
+  for (const [n, b] of [['cov.jpg', 'cover'], ['op.m4a', 'audio'], ['op.png', 'poster']]) {
+    fs.writeFileSync(path.join(MEDIA, 'c', n), Buffer.from(b));
+  }
+  db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, status, published_at, cover_image_url, cover_alt, c2s_attachments)
+              VALUES ('rijk','s1','rijke-post','u1','Rijk','<p>x</p>','published','2026-08-05 10:00:00',
+              '/media/c/cov.jpg','de cover', ?)`)
+    .run(JSON.stringify([{ url: '/media/c/op.m4a', mediaType: 'audio/mp4', name: 'opname.m4a', poster: '/media/c/op.png' }]));
+
+  const arch = AX.buildArchive('me', { exportedAt: 'X' });
+  const rollen = JSON.parse(arch.files.get('posts/rijk.json').toString()).attachment.map((a) => a['shaer:role']);
+  assert.deepEqual(rollen.sort(), ['c2s', 'cover', 'poster'], 'elke bijlage zegt waar hij voor was');
+
+  db.prepare('DELETE FROM posts').run();
+  fs.rmSync(path.join(MEDIA, 'c'), { recursive: true, force: true });
+  AI.importArchive(arch.files, { slug: 'me' });
+
+  const p = db.prepare("SELECT * FROM posts WHERE id = 'rijk'").get();
+  assert.equal(p.cover_image_url, '/media/c/cov.jpg', 'zonder dit staat de post zonder cover terug');
+  assert.equal(p.cover_alt, 'de cover');
+  const c2s = JSON.parse(p.c2s_attachments);
+  assert.equal(c2s[0].url, '/media/c/op.m4a');
+  assert.equal(c2s[0].poster, '/media/c/op.png', 'de poster hoort weer bij zijn opname');
+  assert.ok(fs.existsSync(path.join(MEDIA, 'c', 'cov.jpg')));
+});
+
+test('het MOMENT van een tijdstempel overleeft, ook uit SQL-notatie', () => {
+  // Klonkt schrijft in twee spellingen. Het archief normaliseert naar ISO; het
+  // moment blijft, de spelling niet. Dat is gedocumenteerd gedrag, geen verlies.
+  leeg();
+  db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, status, published_at)
+              VALUES ('tijd','s1','tijd','u1','T','<p>x</p>','published','2026-07-01 12:56:10')`).run();
+  const arch = AX.buildArchive('me', { exportedAt: 'X' });
+  db.prepare('DELETE FROM posts').run();
+  AI.importArchive(arch.files, { slug: 'me' });
+  const p = db.prepare("SELECT published_at FROM posts WHERE id = 'tijd'").get();
+  assert.equal(Date.parse(p.published_at), Date.parse('2026-07-01T12:56:10Z'));
+});
+
+test('zonder manifest is het geen archief', () => {
+  const files = new Map(ARCHIEF.files);
+  files.delete('manifest.json');
+  assert.throws(() => AI.importArchive(files, { slug: 'me' }), /geen manifest/);
+});
+
+test.after(() => { try { fs.rmSync(MEDIA, { recursive: true, force: true }); } catch { /* niets */ } });
Index: test/archive-recovery.test.js
===================================================================
--- test/archive-recovery.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ test/archive-recovery.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,255 @@
+// Herstel uit andermans tijdlijn-cache (shaer-l1v).
+//
+// De opzet bootst de boiert-situatie na: een verloren site waarvan de posts nog
+// in de ap_timeline van een volgende instance staan, en waarvan de mediamap de
+// ramp wel heeft overleefd.
+//
+// De zwaarste toets is de KETTING: cache -> herstelarchief -> importer -> posts.
+// Er is met opzet geen apart herstelpad; als die ketting niet sluit is het hele
+// idee van een gedeeld formaat niets waard.
+//
+// Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import Database from 'better-sqlite3';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://boiert.eu';
+const MEDIA = fs.mkdtempSync(path.join(os.tmpdir(), 'klonkt-herstel-'));
+process.env.MEDIA_PATH = MEDIA;
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AR = await import('../src/services/ArchiveRecoveryService.js');
+const AI = await import('../src/services/ArchiveImportService.js');
+
+const ACTOR = 'https://boiert.eu/ap/users/boiert';
+const werk = fs.mkdtempSync(path.join(os.tmpdir(), 'klonkt-bron-'));
+
+// De GEREDDE mediamap van de verloren site: de database is weg, de bestanden niet.
+const GERED = path.join(werk, 'gered-media');
+fs.mkdirSync(path.join(GERED, 'post-images'), { recursive: true });
+const COVER = Buffer.from('bytes-van-de-cover-van-boiert');
+fs.writeFileSync(path.join(GERED, 'post-images', 'cover.webp'), COVER);
+
+/** Een tijdlijn-cache van een instance die boiert volgt. */
+function maakBron(naam, rijen) {
+  const p = path.join(werk, naam);
+  const s = new Database(p);
+  s.exec(`CREATE TABLE ap_timeline (id TEXT NOT NULL, slug TEXT NOT NULL, author_uri TEXT, author_name TEXT,
+          author_handle TEXT, author_icon TEXT, author_url TEXT, content TEXT, url TEXT, published TEXT,
+          media_json TEXT, created_at DATETIME, boosted INTEGER, liked INTEGER, nsfw INTEGER, cw TEXT,
+          reblog_name TEXT, poll_json TEXT, quote_json TEXT, emoji_json TEXT, embed_json TEXT, UNIQUE(slug, id))`);
+  const ins = s.prepare(`INSERT INTO ap_timeline (id, slug, author_uri, content, url, published, media_json, nsfw, cw, poll_json, quote_json)
+                         VALUES (@id,'volger',@author_uri,@content,@url,@published,@media_json,@nsfw,@cw,@poll_json,@quote_json)`);
+  for (const r of rijen) {
+    ins.run({
+      author_uri: ACTOR, content: '', url: null, published: null, media_json: null,
+      nsfw: 0, cw: null, poll_json: null, quote_json: null, ...r,
+    });
+  }
+  s.close();
+  return p;
+}
+
+const BRON = maakBron('sound-fabrics.sqlite', [
+  {
+    id: 'https://boiert.eu/ap/notes/post-1',
+    content: '<p><strong>Back to 1987!</strong></p><p>Rick Astley in een AI-cover.</p>',
+    url: 'https://boiert.eu/waiting-on-you',
+    published: '2026-04-24T06:32:45.000Z',
+    media_json: JSON.stringify([{ url: 'https://boiert.eu/media/post-images/cover.webp', type: 'image/webp', name: 'de hoes' }]),
+  },
+  {
+    id: 'https://boiert.eu/ap/notes/post-2',
+    content: '<p>Een korte post zonder titel.</p>',
+    url: 'https://boiert.eu/kort',
+    published: '2026-05-01T10:00:00.000Z',
+    cw: 'spoiler', nsfw: 1,
+  },
+  {
+    id: 'https://boiert.eu/ap/notes/post-3',
+    content: '<p><strong>Stemming</strong></p><p>Wat wordt het?</p>',
+    url: 'https://boiert.eu/stemming',
+    published: '2026-05-02T10:00:00.000Z',
+    poll_json: JSON.stringify({ multiple: false, options: [{ name: 'ja', count: 3 }, { name: 'nee', count: 1 }], endTime: '2026-05-09T10:00:00Z', closed: true }),
+  },
+  // Iemand ANDERS in dezelfde tijdlijn: die hoort er niet in.
+  { id: 'https://elders.test/ap/notes/x', author_uri: 'https://elders.test/users/iemand', content: '<p>niet van boiert</p>', url: 'https://elders.test/x' },
+]);
+
+// Een tweede volger die verder terug gaat: de vereniging dekt meer dan een bron.
+const BRON2 = maakBron('tweede.sqlite', [
+  {
+    id: 'https://boiert.eu/ap/notes/post-0',
+    content: '<p><strong>De oudste</strong></p><p>van voor de andere volger.</p>',
+    url: 'https://boiert.eu/de-oudste',
+    published: '2026-01-05T09:00:00.000Z',
+  },
+  // Dezelfde post als in bron 1, maar afgekapt: de RIJKSTE versie hoort te winnen.
+  {
+    id: 'https://boiert.eu/ap/notes/post-1',
+    content: '<p><strong>Back to 1987!</strong></p>',
+    url: 'https://boiert.eu/waiting-on-you',
+    published: '2026-04-24T06:32:45.000Z',
+  },
+]);
+
+const herstel = (extra = {}) => AR.recoverFromCache({
+  sources: [BRON, BRON2], actorUri: ACTOR, mediaRoot: GERED, exportedAt: 'X', ...extra,
+});
+
+test('alleen de posts van de verloren actor komen mee', () => {
+  const { rapport, files } = herstel();
+  assert.equal(rapport.posts, 4);
+  assert.equal(files.has('posts/x.json'), false, 'andermans post hoort er niet in');
+  assert.ok(files.has('posts/post-1.json'));
+});
+
+test('de AP-ids blijven staan -- daar hangt het hele herstel aan', () => {
+  const { files, manifest } = herstel();
+  const o = JSON.parse(files.get('posts/post-1.json').toString());
+  assert.equal(o.id, 'https://boiert.eu/ap/notes/post-1');
+  assert.equal(manifest.origin, 'https://boiert.eu', 'anders weigert de importer de ids te behouden');
+});
+
+test('de titel wordt uit de tekst gevist en de rest blijft heel', () => {
+  const o = JSON.parse(herstel().files.get('posts/post-1.json').toString());
+  assert.equal(o.name, 'Back to 1987!');
+  assert.equal(o.type, 'Article');
+  assert.equal(o.content, '<p>Rick Astley in een AI-cover.</p>');
+  assert.equal(o['shaer:slug'], 'waiting-on-you', 'de slug komt uit de permalink, niet uit de titel');
+});
+
+test('een post zonder vetgedrukte openingsregel houdt zijn hele tekst', () => {
+  const o = JSON.parse(herstel().files.get('posts/post-2.json').toString());
+  assert.equal(o.name, undefined);
+  assert.equal(o.type, 'Note');
+  assert.equal(o.content, '<p>Een korte post zonder titel.</p>');
+  assert.equal(o.summary, 'spoiler');
+  assert.equal(o.sensitive, true);
+});
+
+test('met --houd-titel blijft de tekst onaangeroerd', () => {
+  // Er is geen sluitend signaal dat een vetgedrukte eerste regel een titel was,
+  // dus er moet een uitweg zijn.
+  const o = JSON.parse(herstel({ houdTitelInTekst: true }).files.get('posts/post-1.json').toString());
+  assert.equal(o.name, undefined);
+  assert.match(o.content, /<strong>Back to 1987!<\/strong>/);
+});
+
+test('de media komen van de geredde schijf, niet uit de cache', () => {
+  // De cache bewaart alleen URL's. De bytes overleefden de ramp los van de
+  // database, en het URL-pad is de sleutel ertussen.
+  const { files, rapport } = herstel();
+  const a = JSON.parse(files.get('posts/post-1.json').toString()).attachment[0];
+  assert.equal(a['shaer:availability'], 'included');
+  assert.equal(a['shaer:role'], 'cover');
+  assert.deepEqual(files.get(a.url), COVER);
+  assert.equal(rapport.media, 1);
+});
+
+test('zonder geredde mediamap wordt het gat benoemd, niet verzwegen', () => {
+  const { files, rapport } = herstel({ mediaRoot: null });
+  const a = JSON.parse(files.get('posts/post-1.json').toString()).attachment[0];
+  assert.equal(a['shaer:availability'], 'missing');
+  assert.equal(a['shaer:originalUrl'], 'https://boiert.eu/media/post-images/cover.webp');
+  assert.equal(rapport.mediaMissing, 1);
+});
+
+test('een poll overleeft, inclusief dat hij gesloten was', () => {
+  const o = JSON.parse(herstel().files.get('posts/post-3.json').toString());
+  assert.equal(o.type, 'Question');
+  assert.deepEqual(o.oneOf.map((x) => x.name), ['ja', 'nee']);
+  assert.equal(o.closed, true);
+  assert.equal(o.endTime, '2026-05-09T10:00:00Z');
+});
+
+test('twee bronnen samen dekken meer, en de rijkste versie wint', () => {
+  const { files, rapport } = herstel();
+  assert.ok(files.has('posts/post-0.json'), 'alleen de tweede volger had deze');
+  assert.match(JSON.parse(files.get('posts/post-1.json').toString()).content, /Rick Astley/,
+    'de afgekapte versie uit de tweede bron mag de volledige niet overschrijven');
+  assert.equal(rapport.bronnen.length, 2);
+});
+
+test('het manifest zegt dat dit GERECONSTRUEERD is en wat er mist', () => {
+  // Anders ziet iemand dit over een jaar aan voor een gewone export en denkt hij
+  // dat de site compleet is.
+  const m = herstel().manifest;
+  assert.equal(m['shaer:recovered'].from, 'timeline-cache');
+  assert.deepEqual(m['shaer:recovered'].window, { oldest: '2026-01-05T09:00:00.000Z', newest: '2026-05-02T10:00:00.000Z' });
+  assert.match(m['shaer:recovered'].missing.join(' '), /replies/);
+});
+
+test('DE KETTING: cache -> archief -> importer -> posts', () => {
+  // Bewust geen eigen herstelpad. Zou dit niet sluiten, dan was het gedeelde
+  // formaat een papieren afspraak.
+  db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+    .run('u1', 'boiert', 'b@test', 'x', 'god');
+  db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'boiert', 'Boiert', 'u1');
+
+  const r = AI.importArchive(herstel().files, { slug: 'boiert' });
+  assert.equal(r.idsBehouden, true, 'zelfde origin, dus de boosts elders vinden hun post terug');
+  assert.equal(r.posts, 4);
+
+  const p = db.prepare("SELECT * FROM posts WHERE id = 'post-1'").get();
+  assert.ok(p, 'het oorspronkelijke post-id staat er weer');
+  assert.equal(p.title, 'Back to 1987!');
+  assert.equal(p.slug, 'waiting-on-you');
+  assert.equal(p.status, 'published');
+  assert.equal(p.cover_image_url, '/media/post-images/cover.webp');
+  assert.deepEqual(fs.readFileSync(path.join(MEDIA, 'post-images', 'cover.webp')), COVER);
+
+  const q = db.prepare("SELECT poll_json FROM posts WHERE id = 'post-3'").get();
+  assert.equal(JSON.parse(q.poll_json).closed, true);
+});
+
+test('gehoste audio wordt ook teruggevonden, van een ANDERE map', () => {
+  // Op echte cachedata bleek een deel van de bijlagen niet via /media/ te lopen
+  // maar via /audio/stream/<bestandsnaam>, en dat is AUDIO_DIR -- een andere map
+  // naast de mediamap. Zonder dit blijft de halve muziekcatalogus liggen.
+  const AUDIO = path.join(werk, 'gered-audio');
+  fs.mkdirSync(AUDIO, { recursive: true });
+  const MP3 = Buffer.from('nep-mp3-bytes');
+  fs.writeFileSync(path.join(AUDIO, 'track.mp3'), MP3);
+  const bron = maakBron('audio.sqlite', [{
+    id: 'https://boiert.eu/ap/notes/post-audio',
+    content: '<p>luister</p>',
+    url: 'https://boiert.eu/luister',
+    published: '2026-06-01T10:00:00.000Z',
+    media_json: JSON.stringify([{ url: 'https://boiert.eu/audio/stream/track.mp3', type: 'audio/mpeg' }]),
+  }]);
+  const uit = AR.recoverFromCache({ sources: [bron], actorUri: ACTOR, mediaRoot: GERED, audioRoot: AUDIO, exportedAt: 'X' });
+  const a = JSON.parse(uit.files.get('posts/post-audio.json').toString()).attachment[0];
+  assert.equal(a['shaer:availability'], 'included');
+  assert.deepEqual(uit.files.get(a.url), MP3);
+
+  // En het moet ook de import overleven: het bestand hoort ergens te landen EN
+  // vanuit de kolommen vindbaar te zijn, niet alleen weggeschreven.
+  const r = AI.importArchive(uit.files, { slug: 'boiert' });
+  assert.equal(r.media, 1);
+  const p = db.prepare("SELECT c2s_attachments, cover_image_url FROM posts WHERE id = 'post-audio'").get();
+  const plek = p.cover_image_url;
+  assert.ok(plek && plek.startsWith('/media/'), `de bijlage hoort een plek te hebben, kreeg ${plek}`);
+  assert.ok(fs.existsSync(path.join(MEDIA, plek.slice('/media/'.length))), 'en daar hoort het bestand te staan');
+});
+
+test('zonder actor weigert hij te beginnen', () => {
+  assert.throws(() => AR.recoverFromCache({ sources: [BRON] }), /actorUri is verplicht/);
+});
+
+test('een onleesbare bron stopt het herstel niet, maar wordt wel gemeld', () => {
+  const { rapport } = AR.recoverFromCache({ sources: [BRON, path.join(werk, 'bestaat-niet.sqlite')], actorUri: ACTOR, exportedAt: 'X' });
+  assert.ok(rapport.posts > 0);
+  assert.match(rapport.waarschuwingen.join(' '), /niet te openen/);
+});
+
+test.after(() => {
+  for (const d of [MEDIA, werk]) { try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* niets */ } }
+});
Index: test/authorized-fetch.test.js
===================================================================
--- test/authorized-fetch.test.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ test/authorized-fetch.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -1,23 +1,109 @@
-// FEP-633c §5.3 note: a committed guardian is recognised for authorized fetch.
+// Authorized fetch (shaer-afq): een instance in Mastodons secure mode geeft zijn
+// actor-document -- en dus zijn publieke sleutel -- alleen aan een ONDERTEKEND
+// verzoek. Zonder die handtekening kregen we 401, vonden we geen sleutel, en
+// wezen we elke correct ondertekende Follow van die instance af. Op boiert.eu
+// bleven daardoor vier accounts eindeloos hangen.
+//
+// De fetch is hier gestubd: het gaat om de vraag OF er ondertekend wordt en of
+// er wordt teruggevallen, niet om echte HTTP of crypto.
+//
+// Run: npm test
+
 import { test } from 'node:test';
 import assert from 'node:assert/strict';
 
 process.env.DATABASE_PATH = ':memory:';
-process.env.PUBLIC_BASE_URL = 'https://test.example';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
 
 const dbMod = await import('../src/config/database.js');
 const db = dbMod.default;
 dbMod.initializeDatabase();
-const AP = (await import('../src/services/ActivityPubService.js')).default;
+const AP = await import('../src/services/ActivityPubService.js');
 
-db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@t', 'x', 'god');
-db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,1)').run('s1', 'kid', 'kid', 'u1');
-// Commit a guardian relation: kid (ward) is guarded by mom.
-const MOM = 'https://mom.example/ap/users/mom';
-db.prepare("INSERT INTO ap_guardianships (slug, role, other_uri, status, created_at) VALUES ('kid','ward',?, 'accepted', CURRENT_TIMESTAMP)").run(MOM);
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'u1', 'u1@test', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Me', 'u1');
 
-test('a committed guardian is recognised; a stranger is not', () => {
-  assert.equal(AP.isWardGuardian('kid', MOM), true);
-  assert.equal(AP.isWardGuardian('kid', 'https://x.example/ap/users/stranger'), false);
-  assert.equal(AP.isWardGuardian('nosuch', MOM), false);
+// IP-literals: safeFetch slaat de DNS-lookup over, dus de test blijft offline.
+const OPEN_ACTOR = 'https://203.0.113.30/users/open';       // gewone instance
+const SECURE_ACTOR = 'https://203.0.113.40/users/gesloten'; // authorized fetch
+
+let verzoeken = [];
+const echteFetch = globalThis.fetch;
+globalThis.fetch = async (url, opts = {}) => {
+  const u = String(url);
+  const ondertekend = !!(opts.headers && (opts.headers.Signature || opts.headers.signature));
+  verzoeken.push({ url: u, ondertekend });
+  const doc = (id) => new Response(JSON.stringify({
+    id, type: 'Person', preferredUsername: 'x', inbox: `${id}/inbox`,
+    publicKey: { id: `${id}#main-key`, owner: id, publicKeyPem: '-----BEGIN PUBLIC KEY-----\nx\n-----END PUBLIC KEY-----' },
+  }), { status: 200, headers: { 'content-type': 'application/activity+json' } });
+
+  if (u === OPEN_ACTOR) return doc(OPEN_ACTOR);
+  // De kern van secure mode: onbetekend is het 401, ondertekend krijg je hem wel.
+  if (u === SECURE_ACTOR) return ondertekend ? doc(SECURE_ACTOR) : new Response('unauthorized', { status: 401 });
+  return new Response('not found', { status: 404 });
+};
+
+test('een actor achter authorized fetch wordt nu wél opgehaald', async () => {
+  verzoeken = [];
+  const actor = await AP.fetchActor(SECURE_ACTOR, { asSlug: 'me' });
+  assert.ok(actor, 'de actor hoort binnen te komen');
+  assert.equal(actor.id, SECURE_ACTOR);
+  assert.ok(verzoeken.some((v) => v.url === SECURE_ACTOR && v.ondertekend), 'het verzoek hoort ondertekend te zijn');
 });
+
+test('zonder ondertekenaar blijft dezelfde actor onbereikbaar', async () => {
+  // Dit is precies het oude gedrag, en het bewijst dat de stub echt onderscheid
+  // maakt in plaats van altijd mee te werken.
+  const actor = await AP.fetchActor(SECURE_ACTOR);
+  assert.equal(actor, null);
+});
+
+test('een gewone instance blijft werken, ondertekend of niet', async () => {
+  assert.ok(await AP.fetchActor(OPEN_ACTOR), 'onbetekend');
+  assert.ok(await AP.fetchActor(OPEN_ACTOR, { asSlug: 'me' }), 'ondertekend');
+});
+
+test('een OPEN instance wordt NIET ondertekend opgehaald', () => {
+  // De veiligheidskant van shaer-afq: verifyRequest haalt de keyId-URL op
+  // voordat er iets geverifieerd is, en die URL komt uit een header die iedereen
+  // mag sturen. Tekenden we standaard, dan kan een vreemde ons een ondertekend
+  // verzoek naar een adres van zijn keuze laten sturen, met onze identiteit
+  // eronder. Onbetekend eerst dus, en alleen tekenen als het anders niet lukt.
+  verzoeken = [];
+  return AP.fetchActor(OPEN_ACTOR, { asSlug: 'me' }).then((actor) => {
+    assert.ok(actor);
+    assert.equal(verzoeken.length, 1, 'één poging, geen tweede');
+    assert.equal(verzoeken[0].ondertekend, false, 'en die was onbetekend');
+  });
+});
+
+test('een document zonder sleutel telt als mislukt en leidt tot een ondertekende poging', async () => {
+  // Sommige instances serveren onbetekend wel iets, maar zonder publicKey. Voor
+  // een verificatie hebben we daar niets aan.
+  const KAAL = 'https://203.0.113.50/users/kaal';
+  const stubOrig = globalThis.fetch;
+  globalThis.fetch = async (url, opts = {}) => {
+    const ondertekend = !!(opts.headers && (opts.headers.Signature || opts.headers.signature));
+    if (String(url) === KAAL) {
+      verzoeken.push({ url: String(url), ondertekend });
+      const body = ondertekend
+        ? { id: KAAL, type: 'Person', publicKey: { id: `${KAAL}#k`, owner: KAAL, publicKeyPem: 'x' } }
+        : { id: KAAL, type: 'Person' };   // kaal: geen sleutel
+      return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/activity+json' } });
+    }
+    return stubOrig(url, opts);
+  };
+  verzoeken = [];
+  const actor = await AP.fetchActor(KAAL, { asSlug: 'me' });
+  globalThis.fetch = stubOrig;
+  assert.ok(actor.publicKey && actor.publicKey.publicKeyPem, 'de ondertekende poging levert de sleutel');
+  assert.deepEqual(verzoeken.map((v) => v.ondertekend), [false, true], 'eerst onbetekend, daarna pas ondertekend');
+});
+
+test('een onbekende actor blijft null, ondertekend of niet', async () => {
+  assert.equal(await AP.fetchActor('https://203.0.113.99/users/weg', { asSlug: 'me' }), null);
+});
+
+test.after(() => { globalThis.fetch = echteFetch; });
Index: test/co-location.test.js
===================================================================
--- test/co-location.test.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ test/co-location.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -227,6 +227,19 @@
     // of that job being done.
     wardSlugsOf: 'TODO: gated follows still take the shared-database path for a local guardian',
+    // Tweede plek van diezelfde sluiproute, aangekomen met Fase 2 (shaer-jdb):
+    // followsCollection vult de wachtrij voor een ward op DEZE instance uit
+    // ap_pending_follows. Hoort mee te verdwijnen met wardSlugsOf hierboven --
+    // niet apart, want het is een sluiproute en geen tweede probleem.
+    slugOf: 'TODO: idem, nu ook in de follows-wachtrij (shaer-h6u)',
   };
-  const files = ['src/services/guardianship/handshake.js', 'src/routes/guardian.js'];
+  // De hele module, niet twee bestanden. Het commentaar hierboven zegt "elke
+  // plek die vraagt of deze actor van ons is", en dat werd tot nu toe afgemeten
+  // aan twee namen -- waardoor een nieuwe sluiproute in een DERDE bestand er
+  // geruisloos doorheen kwam. Dat is precies hoe deze er kwam.
+  const files = [
+    ...fs.readdirSync('src/services/guardianship').filter((f) => f.endsWith('.js'))
+      .map((f) => `src/services/guardianship/${f}`),
+    'src/routes/guardian.js',
+  ];
   // Only top-level declarations name a scope; an indented `const base = ...` is
   // a local and would otherwise take the blame for its enclosing function. A
Index: test/feed-wait.test.js
===================================================================
--- test/feed-wait.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ test/feed-wait.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,196 @@
+// Wachten op nieuws, als uitbreiding van de inbox-lezing (shaer-n05).
+//
+// Geen tweede endpoint en geen seintje-formaat: dezelfde lezing, die desgevraagd
+// even blijft hangen. Wat hier bewaakt wordt is de MERKSTEEN -- als die niet
+// beweegt bij iets dat de inbox wel zou tonen, blijft een wachtende client
+// slapen terwijl er nieuws is. Dat is erger dan niet wachten, want het lijkt te
+// werken.
+//
+// Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = await import('../src/services/ActivityPubService.js');
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'u1', 'u1@t', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Me', 'u1');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s2', 'buur', 'Buur', 'u1');
+db.prepare('INSERT INTO posts (id, site_id, slug, author_id, title, content, status) VALUES (?,?,?,?,?,?,?)')
+  .run('p1', 's1', 'mijn-post', 'u1', 'Post', '<p>x</p>', 'published');
+
+let n = 0;
+const nieuwePost = (slug = 'me') => db.prepare(
+  "INSERT INTO ap_timeline (id, slug, author_uri, content) VALUES (?,?,'https://r.test/u/a','<p>x</p>')",
+).run(`https://r.test/n/${++n}`, slug);
+
+test('een verse tijdlijnpost beweegt de merksteen', () => {
+  const voor = AP.feedCursor('me');
+  nieuwePost();
+  assert.notEqual(AP.feedCursor('me'), voor);
+});
+
+test('en zo ook de andere drie poten van de inbox', () => {
+  // De inbox voegt vier bronnen samen. Ontbreekt er een in de merksteen, dan
+  // wordt een client niet wakker van precies dat soort nieuws.
+  const bericht = () => db.prepare(
+    "INSERT INTO ap_mentions (slug, object_uri, actor_uri, content) VALUES ('me',?, 'https://r.test/u/a','<p>hoi</p>')",
+  ).run(`https://r.test/m/${++n}`);
+  const antwoord = () => db.prepare(
+    `INSERT INTO ap_interactions (kind, post_id, object_uri, actor_uri, content)
+     VALUES ('reply','p1',?, 'https://r.test/u/a','<p>hoi</p>')`,
+  ).run(`https://r.test/r/${++n}`);
+  const verstuurd = () => db.prepare(
+    "INSERT INTO ap_outbox (id, site_slug, post_id, content) VALUES (?,'me','p1','<p>x</p>')",
+  ).run(`o${++n}`);
+
+  for (const [naam, doen] of [['bericht', bericht], ['antwoord', antwoord], ['verstuurd', verstuurd]]) {
+    const voor = AP.feedCursor('me');
+    doen();
+    assert.notEqual(AP.feedCursor('me'), voor, `${naam} hoort de merksteen te bewegen`);
+  }
+});
+
+test('een BEWERKING beweegt hem ook', () => {
+  // De reden dat de merksteen niet meer op MAX(rowid) leunt. Een Update schrijft
+  // dezelfde rij, dus de rowid bleef staan en een wachtende client sliep door.
+  db.prepare("INSERT INTO ap_timeline (id, slug, author_uri, content) VALUES ('bw','me','https://r.test/u/a','<p>oud</p>')").run();
+  const voor = AP.feedCursor('me');
+  db.prepare("UPDATE ap_timeline SET content = '<p>nieuw</p>' WHERE id = 'bw'").run();
+  assert.notEqual(AP.feedCursor('me'), voor);
+});
+
+test('en een VERWIJDERING, ook als het niet de laatste is', () => {
+  db.prepare("INSERT INTO ap_timeline (id, slug, author_uri, content) VALUES ('later','me','https://r.test/u/a','<p>x</p>')").run();
+  const voor = AP.feedCursor('me');
+  db.prepare("DELETE FROM ap_timeline WHERE id = 'bw'").run();
+  assert.notEqual(AP.feedCursor('me'), voor, 'anders blijft een verwijderde post staan tot je toevallig ververst');
+});
+
+test('maar een LIKE of BOOST van jezelf niet', () => {
+  // De val waar dit bijna in liep: een like schrijft ap_timeline.liked en een 🔁
+  // schrijft .boosted. Zonder de UPDATE OF-kolomlijst zou je eigen like het
+  // bericht als BEWERKT merken en elke wachtende client wekken.
+  const voor = AP.feedCursor('me');
+  db.prepare("UPDATE ap_timeline SET liked = 1 WHERE id = 'later'").run();
+  db.prepare("UPDATE ap_timeline SET boosted = 1 WHERE id = 'later'").run();
+  db.prepare("UPDATE ap_interactions SET acted_like = 1 WHERE post_id = 'p1'").run();
+  assert.equal(AP.feedCursor('me'), voor, 'je eigen reactie is geen nieuws');
+});
+
+test('een INSERT OR IGNORE die niets doet beweegt hem niet', () => {
+  const voor = AP.feedCursor('me');
+  db.prepare("INSERT OR IGNORE INTO ap_timeline (id, slug, author_uri, content) VALUES ('later','me','https://r.test/u/a','<p>x</p>')").run();
+  assert.equal(AP.feedCursor('me'), voor);
+});
+
+test('de teller loopt nooit achteruit', () => {
+  // Met MAX(rowid) zakte hij terug zodra de nieuwste rij verdween, en dan denkt
+  // een client dat er niets gebeurd is.
+  const hoog = parseInt(AP.feedCursor('me'), 10);
+  db.prepare('DELETE FROM ap_timeline WHERE slug = ?').run('me');
+  assert.ok(parseInt(AP.feedCursor('me'), 10) >= hoog);
+});
+
+test('en hij vertelt WAT er veranderd is', () => {
+  // Dit is wat een revisieteller niet kan en deze tabel gratis meegeeft: de
+  // "bewerkt"-markering hoeft er later geen eigen bouwsel voor te worden.
+  const stand = (uri) => (AP.feedChangesSince('me', 0).find((r) => r.object_uri === uri) || {}).kind;
+
+  db.prepare("INSERT INTO ap_timeline (id, slug, author_uri, content) VALUES ('vers','me','https://r.test/u/a','<p>x</p>')").run();
+  assert.equal(stand('vers'), 'new');
+
+  db.prepare("UPDATE ap_timeline SET content = '<p>anders</p>' WHERE id = 'vers'").run();
+  assert.equal(stand('vers'), 'updated');
+
+  db.prepare("DELETE FROM ap_timeline WHERE id = 'vers'").run();
+  assert.equal(stand('vers'), 'deleted');
+});
+
+test('het is een STAND en geen logboek: bewerkt-en-toen-weg leest als weg', () => {
+  // Een rij per bericht, niet een rij per gebeurtenis. Vijf keer bewerken blijft
+  // één rij, en dat is waarom er niets te snoeien valt. Mijn eerste test ging uit
+  // van een log en viel daar terecht over.
+  const alles = AP.feedChangesSince('me', 0);
+  const perUri = new Set(alles.map((r) => r.object_uri));
+  assert.equal(alles.length, perUri.size, 'geen enkel bericht komt twee keer voor');
+});
+
+test('nieuws van een ANDER account laat je merksteen staan', () => {
+  const voor = AP.feedCursor('me');
+  nieuwePost('buur');
+  assert.equal(AP.feedCursor('me'), voor, 'anders wordt iedereen wakker van andermans post');
+});
+
+test('is er al iets veranderd, dan wordt er niet gewacht', async () => {
+  const oud = AP.feedCursor('me');
+  nieuwePost();
+  const begin = Date.now();
+  const uit = await AP.waitForFeedChange('me', { since: oud, waitMs: 5000, tickMs: 20 });
+  assert.equal(uit.changed, true);
+  assert.equal(uit.waited, false, 'meteen antwoorden, niet eerst de tijd volmaken');
+  assert.ok(Date.now() - begin < 500);
+});
+
+test('zonder sinds is het gewoon de huidige stand', async () => {
+  const uit = await AP.waitForFeedChange('me', { waitMs: 5000, tickMs: 20 });
+  assert.equal(uit.changed, false);
+  assert.equal(uit.waited, false);
+  assert.equal(uit.cursor, AP.feedCursor('me'));
+});
+
+test('wachten wordt afgebroken zodra er iets binnenkomt', async () => {
+  const oud = AP.feedCursor('me');
+  const begin = Date.now();
+  setTimeout(() => nieuwePost(), 60);
+  const uit = await AP.waitForFeedChange('me', { since: oud, waitMs: 4000, tickMs: 20 });
+  assert.equal(uit.changed, true);
+  assert.equal(uit.waited, true);
+  assert.ok(Date.now() - begin < 2000, 'hij hoort wakker te worden, niet de tijd vol te maken');
+  assert.notEqual(uit.cursor, oud, 'en de nieuwe merksteen komt mee');
+});
+
+test('gebeurt er niets, dan komt hij leeg terug binnen de tijd', async () => {
+  const oud = AP.feedCursor('me');
+  const begin = Date.now();
+  const uit = await AP.waitForFeedChange('me', { since: oud, waitMs: 150, tickMs: 20 });
+  assert.equal(uit.changed, false);
+  assert.equal(uit.waited, true);
+  assert.equal(uit.cursor, oud);
+  assert.ok(Date.now() - begin >= 100, 'hij hoort wel echt gewacht te hebben');
+  assert.ok(Date.now() - begin < 3000, 'en niet langer dan gevraagd');
+});
+
+test('ophangen breekt het wachten af', async () => {
+  // Zonder dit blijft er een timer draaien voor een client die er niet meer is.
+  const oud = AP.feedCursor('me');
+  const ac = new AbortController();
+  const begin = Date.now();
+  setTimeout(() => ac.abort(), 60);
+  const uit = await AP.waitForFeedChange('me', { since: oud, waitMs: 4000, tickMs: 20, signal: ac.signal });
+  assert.equal(uit.changed, false);
+  assert.ok(Date.now() - begin < 2000);
+});
+
+test('meer dan vier wachters tegelijk: de rest krijgt meteen antwoord', async () => {
+  // Een client met een kapotte herverbind-lus mag de instance niet vastzetten.
+  // De overtolligen krijgen geen fout maar gewoon de huidige stand.
+  const oud = AP.feedCursor('me');
+  const lopend = [];
+  for (let i = 0; i < 4; i++) lopend.push(AP.waitForFeedChange('me', { since: oud, waitMs: 300, tickMs: 20 }));
+  await new Promise((r) => setTimeout(r, 30));
+  const vijfde = await AP.waitForFeedChange('me', { since: oud, waitMs: 4000, tickMs: 20 });
+  assert.equal(vijfde.busy, true);
+  assert.equal(vijfde.waited, false);
+  await Promise.all(lopend);
+  // En daarna is er weer plek: de teller moet netjes teruglopen.
+  const daarna = await AP.waitForFeedChange('me', { since: oud, waitMs: 60, tickMs: 20 });
+  assert.notEqual(daarna.busy, true);
+});
Index: test/forwarded-activities.test.js
===================================================================
--- test/forwarded-activities.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ test/forwarded-activities.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,228 @@
+// Doorgestuurde activiteiten (shaer-s8k): een geldige handtekening van iemand
+// anders dan de auteur is doorsturen, geen vervalsing. We geloven de bezorgde
+// inhoud niet en halen het object bij de bron op.
+//
+// Deze tests dekken dereferenceForwarded via de echte handleInbox, met een
+// gestubde fetch: het gaat om de BESLISSING (accepteren of weigeren) en om wat
+// er wordt opgeslagen, niet om HTTP.
+//
+// Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = 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');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Me', 'u1');
+
+// IP-literals uit de documentatierange (TEST-NET-3): safeFetch doet dan GEEN
+// DNS-lookup (zie assertPublicHost) en ze staan niet in de geblokkeerde ranges,
+// dus de SSRF-preflight laat ze door en de gestubde fetch vangt het verzoek op.
+// Met verzonnen hostnamen faalde de preflight en kwam het nooit tot ophalen.
+const AUTEUR = 'https://203.0.113.10/users/anna';
+const DOORSTUURDER = 'https://203.0.113.20/users/relay';
+const NOTE_ID = 'https://203.0.113.10/notes/1';
+
+// Wat de bron teruggeeft als we het object ophalen. Per test aan te passen.
+let bron = null;
+let opgehaald = [];
+const echteFetch = globalThis.fetch;
+globalThis.fetch = async (url, opts = {}) => {
+  const u = String(url);
+  opgehaald.push({ url: u, ondertekend: !!(opts.headers && (opts.headers.Signature || opts.headers.signature)) });
+  if (u === NOTE_ID && bron) {
+    return new Response(JSON.stringify(bron), { status: 200, headers: { 'content-type': 'application/activity+json' } });
+  }
+  return new Response('not found', { status: 404 });
+};
+
+// Een note die WIJ kennen: doorsturen gebeurt omdat wij in de thread zitten, en
+// sinds shaer-drf dereferencen we alleen als inReplyTo daarheen wijst.
+const ONZE_NOTE = 'https://203.0.113.10/notes/van-ons';
+db.prepare(`INSERT OR IGNORE INTO ap_timeline (id, slug, author_uri, author_name, content, created_at)
+            VALUES (?,?,?,?,?,?)`).run(ONZE_NOTE, 'me', AUTEUR, 'Anna', '<p>x</p>', '2026-08-06 09:00:00');
+
+/** Een doorgestuurde Create: ondertekend door de doorstuurder, geschreven door de auteur. */
+function doorgestuurd(objectOverride) {
+  return {
+    type: 'Create',
+    actor: AUTEUR,
+    object: objectOverride !== undefined ? objectOverride : {
+      id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, inReplyTo: ONZE_NOTE,
+      content: '<p>BEZORGDE versie</p>', to: ['https://www.w3.org/ns/activitystreams#Public'],
+    },
+  };
+}
+const req = (body) => ({ body, headers: { signature: 'keyId="x",signature="y"' }, ip: '203.0.113.9' });
+const alsDoorstuurder = { id: DOORSTUURDER };
+
+test('doorgestuurde Create wordt geaccepteerd na ophalen bij de bron', async () => {
+  bron = { id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, content: '<p>ECHTE versie</p>' };
+  opgehaald = [];
+  const act = doorgestuurd();
+  const status = await AP.handleInbox(req(act), 'me', alsDoorstuurder);
+  assert.notEqual(status, 401, 'een doorgestuurde reactie hoort niet meer geweigerd te worden');
+  assert.ok(opgehaald.some((v) => v.url === NOTE_ID), 'het object hoort bij de bron opgehaald te zijn');
+  // Wat telt: de OPGEHAALDE inhoud wordt gebruikt, niet wat de doorstuurder gaf.
+  assert.match(act.object.content, /ECHTE versie/);
+  assert.doesNotMatch(act.object.content, /BEZORGDE/);
+});
+
+test('een doorstuurder die de inhoud verdraait wint daar niets mee', async () => {
+  // Hetzelfde echte id, maar de bezorgde payload liegt over de inhoud. De bron
+  // is de waarheid; de payload wordt weggegooid.
+  bron = { id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, content: '<p>ECHTE versie</p>' };
+  const act = doorgestuurd({ id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, inReplyTo: ONZE_NOTE, content: '<p>KOOP MIJN MUNTEN</p>' });
+  await AP.handleInbox(req(act), 'me', alsDoorstuurder);
+  assert.doesNotMatch(act.object.content, /MUNTEN/);
+});
+
+test('object op een ANDERE host dan de geclaimde actor wordt geweigerd', async () => {
+  // Zonder deze ankereis wijst een doorsturer je naar een host die hij zelf
+  // beheert, waar attributedTo alles kan beweren.
+  bron = { id: 'https://203.0.113.66/notes/1', type: 'Note', attributedTo: AUTEUR, inReplyTo: ONZE_NOTE, content: '<p>x</p>' };
+  const act = doorgestuurd({ id: 'https://203.0.113.66/notes/1', type: 'Note', attributedTo: AUTEUR, inReplyTo: ONZE_NOTE, content: '<p>x</p>' });
+  const status = await AP.handleInbox(req(act), 'me', alsDoorstuurder);
+  assert.equal(status, 401);
+});
+
+test('de bron die het object NIET aan de geclaimde actor toeschrijft → geweigerd', async () => {
+  bron = { id: NOTE_ID, type: 'Note', attributedTo: 'https://203.0.113.10/users/iemandanders', content: '<p>x</p>' };
+  const status = await AP.handleInbox(req(doorgestuurd()), 'me', alsDoorstuurder);
+  assert.equal(status, 401);
+});
+
+test('bron onbereikbaar → geweigerd, geen twijfelgeval opgeslagen', async () => {
+  bron = null;   // de stub geeft 404
+  const status = await AP.handleInbox(req(doorgestuurd()), 'me', alsDoorstuurder);
+  assert.equal(status, 401);
+});
+
+test('een doorgestuurde Delete blijft geweigerd', async () => {
+  // Niet te dereferencen: het object is per definitie weg.
+  bron = { id: NOTE_ID, type: 'Note', attributedTo: AUTEUR };
+  const act = { type: 'Delete', actor: AUTEUR, object: NOTE_ID };
+  const status = await AP.handleInbox(req(act), 'me', alsDoorstuurder);
+  assert.equal(status, 401);
+});
+
+test('een ONGETEKENDE activiteit blijft geweigerd, ook met een geldig object', async () => {
+  // Zonder bewijs van wie het bezorgde is er niets om op te bouwen; dan mag er
+  // ook niet gedereferenced worden.
+  bron = { id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, content: '<p>x</p>' };
+  opgehaald = [];
+  const status = await AP.handleInbox({ body: doorgestuurd(), headers: {}, ip: '203.0.113.9' }, 'me', null);
+  assert.equal(status, 401);
+  assert.equal(opgehaald.some((v) => v.url === NOTE_ID), false, 'er hoort niet eens opgehaald te worden');
+});
+
+test('een object-id dat naar iets anders omleidt wordt geweigerd', async () => {
+  // De bron geeft een ander id terug dan we opvroegen: dan weten we niet wat we
+  // in handen hebben.
+  bron = { id: 'https://203.0.113.10/notes/999', type: 'Note', attributedTo: AUTEUR, content: '<p>x</p>' };
+  const status = await AP.handleInbox(req(doorgestuurd()), 'me', alsDoorstuurder);
+  assert.equal(status, 401);
+});
+
+test('een antwoord op iets dat we NIET kennen wordt niet opgehaald', async () => {
+  // De vernauwing uit shaer-drf. Zonder deze eis zijn claimedActor en object.id
+  // allebei door de aanvaller gekozen en eist het host-anker alleen dat ze aan
+  // elkaar gelijk zijn -- dan kan iedereen met een werkende actor ons naar elke
+  // URL sturen.
+  bron = { id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, content: '<p>x</p>' };
+  opgehaald = [];
+  const act = doorgestuurd({
+    id: NOTE_ID, type: 'Note', attributedTo: AUTEUR,
+    inReplyTo: 'https://203.0.113.10/notes/kennen-we-niet', content: '<p>x</p>',
+  });
+  const status = await AP.handleInbox(req(act), 'me', alsDoorstuurder);
+  assert.equal(status, 401);
+  assert.equal(opgehaald.some((v) => v.url === NOTE_ID), false, 'er hoort niet eens opgehaald te worden');
+});
+
+test('zonder inReplyTo wordt er niets opgehaald', async () => {
+  bron = { id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, content: '<p>x</p>' };
+  opgehaald = [];
+  const act = doorgestuurd({ id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, content: '<p>x</p>' });
+  assert.equal(await AP.handleInbox(req(act), 'me', alsDoorstuurder), 401);
+  assert.equal(opgehaald.some((v) => v.url === NOTE_ID), false);
+});
+
+test('de dereference haalt ONBETEKEND op als dat volstaat', async () => {
+  // Anders kan een ander ons een ondertekend verzoek naar een adres van zijn
+  // keuze laten sturen, met onze identiteit eronder.
+  //
+  // Eigen id: NOTE_ID is in een eerdere test mislukt en zit dus in de negatieve
+  // cache -- die zou deze poging overslaan. Dat de test daarop stukliep, is het
+  // bewijs dat de cache doet wat hij moet.
+  const VERS = 'https://203.0.113.10/notes/vers';
+  const stubOrig = globalThis.fetch;
+  globalThis.fetch = async (url, opts = {}) => {
+    if (String(url) === VERS) {
+      opgehaald.push({ url: VERS, ondertekend: !!(opts.headers && (opts.headers.Signature || opts.headers.signature)) });
+      return new Response(JSON.stringify({ id: VERS, type: 'Note', attributedTo: AUTEUR, content: '<p>x</p>' }),
+        { status: 200, headers: { 'content-type': 'application/activity+json' } });
+    }
+    return stubOrig(url, opts);
+  };
+  opgehaald = [];
+  const act = doorgestuurd({ id: VERS, type: 'Note', attributedTo: AUTEUR, inReplyTo: ONZE_NOTE, content: '<p>y</p>' });
+  const status = await AP.handleInbox(req(act), 'me', alsDoorstuurder);
+  globalThis.fetch = stubOrig;
+  assert.notEqual(status, 401);
+  const pogingen = opgehaald.filter((v) => v.url === VERS);
+  assert.equal(pogingen.length, 1, 'één poging');
+  assert.equal(pogingen[0].ondertekend, false, 'en die was onbetekend');
+});
+
+test('een mislukte poging wordt onthouden, zodat een retry hem niet herhaalt', async () => {
+  // Mastodon herhaalt dagenlang; zonder cache doet elke herhaling de fetch
+  // opnieuw. De tweede bezorging hoort geen tweede fetch op te leveren.
+  const MIS = 'https://203.0.113.10/notes/mislukt';
+  bron = null;   // de stub geeft 404
+  const act = () => doorgestuurd({ id: MIS, type: 'Note', attributedTo: AUTEUR, inReplyTo: ONZE_NOTE, content: '<p>x</p>' });
+  opgehaald = [];
+  assert.equal(await AP.handleInbox(req(act()), 'me', alsDoorstuurder), 401);
+  const na1 = opgehaald.filter((v) => v.url === MIS).length;
+  assert.ok(na1 >= 1, 'de eerste poging haalt wel op');
+  opgehaald = [];
+  assert.equal(await AP.handleInbox(req(act()), 'me', alsDoorstuurder), 401);
+  assert.equal(opgehaald.filter((v) => v.url === MIS).length, 0, 'de tweede niet meer');
+});
+
+test('op de GEDEELDE inbox wordt de bron ook ondertekend opgehaald', async () => {
+  // Doorstuurverkeer landt op /ap/inbox, want we adverteren een sharedInbox --
+  // en daar is slugParam null. signedGetJson valt bij een lege slug terug op een
+  // ONBETEKENDE GET, dus een bron in secure mode was langs deze weg helemaal niet
+  // te dereferencen. Elke lokale actor is een geldige ondertekenaar, net als in
+  // verifyRequest sinds shaer-afq.
+  const GESLOTEN = 'https://203.0.113.10/notes/secure-mode';   // eigen id: buiten de negatieve cache
+  const note = {
+    id: GESLOTEN, type: 'Note', attributedTo: AUTEUR, inReplyTo: ONZE_NOTE,
+    content: '<p>uit secure mode</p>', to: ['https://www.w3.org/ns/activitystreams#Public'],
+  };
+  const stub = globalThis.fetch;
+  const pogingen = [];
+  globalThis.fetch = async (url, opts = {}) => {
+    if (String(url) !== GESLOTEN) return stub(url, opts);
+    const ondertekend = !!(opts.headers && (opts.headers.Signature || opts.headers.signature));
+    pogingen.push(ondertekend);
+    return ondertekend
+      ? new Response(JSON.stringify(note), { status: 200, headers: { 'content-type': 'application/activity+json' } })
+      : new Response('unauthorized', { status: 401 });
+  };
+  const status = await AP.handleInbox(req(doorgestuurd(note)), null, alsDoorstuurder);
+  globalThis.fetch = stub;
+  assert.equal(status, 202, 'de doorgestuurde Create hoort geaccepteerd te worden');
+  assert.deepEqual(pogingen, [false, true], 'eerst onbetekend, daarna pas ondertekend');
+});
+
+test.after(() => { globalThis.fetch = echteFetch; });
Index: test/guardian-follow-queues.test.js
===================================================================
--- test/guardian-follow-queues.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ test/guardian-follow-queues.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,86 @@
+// Guardianship Fase 2 (shaer-jdb): gate-verzoeken bereiken een C2S-client.
+//
+// De gating zelf werkt sinds shaer-hxg. Wat ontbrak was het DOORGEVEN: de
+// wachtrij `follows` gaf hardgecodeerd een lege lijst terug ("not built in
+// Klonkt yet (Fase 2)") en `outgoing-follows` serveerde alleen de ward-kant.
+// Daardoor bleven beide secties in Shaer altijd leeg.
+//
+// En daaronder zat de echte fout: shaer:direction werd bij het versturen gezet
+// en nergens gelezen, dus een UITGAAND verzoek werd opgeslagen als "deze ward
+// wil deze ward volgen" met het doel weggegooid.
+//
+// Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const G = await import('../src/services/guardianship/index.js');
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'oma', 'o@test', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'oma', 'Oma', 'u1');
+
+const WARD = 'https://kind.test/ap/users/kind';
+const VREEMDE = 'https://elders.test/users/vreemde';
+const DOEL = 'https://muziek.test/users/band';
+const ME = 'https://klonkt.test/ap/users/oma';
+
+test('een INKOMEND verzoek komt in de follows-wachtrij', () => {
+  G.follows.recordReview('oma', {
+    id: 'f-in', wardUri: WARD, follower: VREEMDE, followerHandle: '@vreemde@elders.test',
+    direction: 'incoming',
+  });
+  const c = G.followsCollection(`${ME}/queues/follows`, 'oma', ME);
+  assert.equal(c.totalItems, 1, 'deze wachtrij gaf hiervoor altijd een lege lijst terug');
+  const it = c.orderedItems[0];
+  assert.equal(it.actor, VREEMDE, 'de vreemde is de volger');
+  assert.equal(it.object, WARD, 'en de ward is het doel');
+  assert.equal(it['shaer:direction'], 'incoming');
+});
+
+test('een UITGAAND verzoek komt in de outgoing-wachtrij, met het doel erin', () => {
+  // De kern van de bug: hiervoor werd dit als inkomend opgeslagen met ward ==
+  // follower, en het doel -- waar het antwoord over gaat -- viel weg.
+  G.follows.recordReview('oma', {
+    id: 'f-uit', wardUri: WARD, follower: WARD, direction: 'outgoing',
+    target: DOEL, targetHandle: '@band@muziek.test',
+  });
+  const c = G.outgoingFollowsCollection(`${ME}/queues/outgoing-follows`, 'oma', ME);
+  const it = c.orderedItems.find((x) => x.id === 'f-uit');
+  assert.ok(it, 'een guardian zag hier nooit iets: de wachtrij serveerde alleen de ward-kant');
+  assert.equal(it.actor, WARD, 'de ward is hier de volger');
+  assert.equal(it.object, DOEL);
+  assert.equal(it['shaer:target'], DOEL, 'zonder dit valt er niets te beoordelen');
+  assert.equal(it['shaer:targetHandle'], '@band@muziek.test');
+  assert.equal(it['shaer:direction'], 'outgoing');
+});
+
+test('de twee richtingen lopen niet door elkaar', () => {
+  const inn = G.followsCollection(`${ME}/q/f`, 'oma', ME).orderedItems.map((x) => x.id);
+  const uit = G.outgoingFollowsCollection(`${ME}/q/o`, 'oma', ME).orderedItems.map((x) => x.id);
+  assert.deepEqual(inn, ['f-in']);
+  assert.ok(uit.includes('f-uit') && !uit.includes('f-in'));
+});
+
+test('bij een remote ward wordt het aantal guardians WEGGELATEN, niet op nul gezet', () => {
+  // De guardian-set van een remote ward wordt op diens eigen server bijgehouden.
+  // Nul sturen zou lezen als "dit kind heeft geen guardians", en dat is het
+  // tegenovergestelde van onbekend.
+  const it = G.followsCollection(`${ME}/q/f`, 'oma', ME).orderedItems[0];
+  assert.equal(it['shaer:guardianCount'], undefined);
+});
+
+test('een oude rij zonder richting telt als inkomend', () => {
+  // Bestaande rijen missen de kolom en zijn niet te repareren -- de informatie
+  // stond er nooit in. Ze horen terug te vallen op het geval dat ze toen waren.
+  db.prepare(`INSERT INTO ap_follow_reviews (id, guardian_slug, ward_uri, follower_uri, direction)
+              VALUES ('f-oud','oma',?,?,NULL)`).run(WARD, VREEMDE);
+  const inn = G.followsCollection(`${ME}/q/f`, 'oma', ME).orderedItems.map((x) => x.id);
+  assert.ok(inn.includes('f-oud'));
+});
Index: test/guardian-gate-panel.test.js
===================================================================
--- test/guardian-gate-panel.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ test/guardian-gate-panel.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,84 @@
+// Het gate-paneel per ward (shaer-ahy.1).
+//
+// Wat gated wordt is een ontwerpkeuze van de implementatie -- de FEP levert het
+// mechanisme en een paar voorbeelden, niet de lijst. GATE_CATALOGUE is die lijst
+// op een plek, en gateRows maakt er de rijen van die het paneel toont.
+//
+// De regels die hier bewaakt worden zijn geen opmaak maar betekenis: een gate
+// die je niet kunt verzetten hoort tOch zichtbaar te zijn, onbekend is niet uit,
+// en er wordt geen drempel verzonnen die we niet kennen.
+//
+// Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+const gated = await import('../src/services/guardianship/gated.js');
+
+const rij = (rows, f) => rows.find((r) => r.feature === f);
+
+test('elke gate uit de catalogus krijgt een rij, ook de vaste', () => {
+  // Een paneel dat alleen verstelbare dingen toont verzwijgt de helft van wat er
+  // voor dit kind geldt.
+  const rows = gated.gateRows({});
+  assert.equal(rows.length, gated.GATE_CATALOGUE.length);
+  assert.ok(rij(rows, 'shaer:follows'), 'volgverzoeken zijn altijd gated en horen er tOch te staan');
+  assert.equal(rij(rows, 'shaer:follows').adjustable, false, 'maar niet te verzetten');
+});
+
+test('het soort staat erbij, want ze werken niet hetzelfde', () => {
+  const rows = gated.gateRows({});
+  assert.equal(rij(rows, 'shaer:externalEmbeds').kind, 'setting');
+  assert.equal(rij(rows, 'shaer:follows').kind, 'perRequest');
+});
+
+test('de drempel volgt het aantal guardians', () => {
+  const drie = gated.gateRows({ guardianCount: 3 });
+  assert.deepEqual(rij(drie, 'shaer:externalEmbeds').threshold, { need: 2, of: 3 });
+  const een = gated.gateRows({ guardianCount: 1 });
+  assert.deepEqual(rij(een, 'shaer:externalEmbeds').threshold, { need: 1, of: 1 });
+});
+
+test('een ONBEKEND aantal guardians levert GEEN drempel op', () => {
+  // Bij een ward elders wordt die set op diens eigen server bijgehouden. Een
+  // verzonnen getal leest als een feit, en dit is precies waar een guardian op
+  // afgaat voordat hij iets voorstelt.
+  const rows = gated.gateRows({ guardianCount: null });
+  assert.equal(rij(rows, 'shaer:externalEmbeds').threshold, null);
+});
+
+test('afspelen is dicht zolang zien UIT staat', () => {
+  const rows = gated.gateRows({ settings: { 'shaer:externalEmbeds': false } });
+  const p = rij(rows, 'shaer:externalPlayback');
+  assert.equal(p.adjustable, false);
+  assert.equal(p.blockedBy, 'shaer:externalEmbeds');
+});
+
+test('maar ONBEKEND is niet hetzelfde als uit', () => {
+  // Verbergen bij onbekend betekende ooit dat een guardian elders afspelen nooit
+  // kon voorstellen -- daar ging een hele voorstelronde de verkeerde gate in.
+  const rows = gated.gateRows({ settings: { 'shaer:externalEmbeds': null } });
+  const p = rij(rows, 'shaer:externalPlayback');
+  assert.equal(p.adjustable, true);
+  assert.equal(p.blockedBy, undefined);
+});
+
+test('een lopend voorstel hangt aan zijn eigen gate', () => {
+  const rows = gated.gateRows({
+    proposals: [{ feature: 'shaer:externalPlayback', value: true, status: 'open' }],
+  });
+  assert.equal(rij(rows, 'shaer:externalPlayback').proposal.status, 'open');
+  assert.equal(rij(rows, 'shaer:externalEmbeds').proposal, undefined);
+});
+
+test('wat er wacht staat bij de gate waar het op wacht', () => {
+  const rows = gated.gateRows({ waiting: { 'shaer:follows': 2 } });
+  assert.equal(rij(rows, 'shaer:follows').waiting, 2);
+});
+
+test('omkeerbaarheid staat per gate genoteerd', () => {
+  // Nu is alles omkeerbaar. Zodra independence erbij komt (shaer-90v) is dat het
+  // niet, en dan moet het paneel dat kunnen zeggen zonder verbouwing.
+  for (const r of gated.gateRows({})) assert.equal(typeof r.reversible, 'boolean');
+});
Index: test/guardian-gate-push.test.js
===================================================================
--- test/guardian-gate-push.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ test/guardian-gate-push.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,82 @@
+// Meldingen bij gated settings (shaer-fax, fase 1 -- de PWA eerst).
+//
+// De guardianship-module zendt VEERTIEN soorten gebeurtenissen uit; de
+// push-brug kende er vijf en deed `if (!texts) return`. Alles daarbuiten viel
+// stil weg -- inclusief de twee die er voor een guardian het meest toe doen:
+//
+//   gated_review   een mede-guardian heeft iets voorgesteld en JOUW antwoord is
+//                  nodig. Zonder melding loopt het venster leeg en verloopt het
+//                  voorstel. Een drempel die niemand ziet is geen drempel.
+//   gated_outcome  er is besloten.
+//
+// Getoetst wordt de BESLISSING (welke melding hoort hierbij), niet de bezorging.
+// Of web-push zelf werkt heeft zijn eigen leven.
+//
+// Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = await import('../src/services/ActivityPubService.js');
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'g', 'g@test', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'oma', 'Oma', 'u1');
+
+const KIND = 'https://kind.test/ap/users/kind';
+const push = (ev) => AP.guardianEventPush('oma', ev);
+
+test('een voorstel dat MIJN antwoord nodig heeft geeft een melding', () => {
+  const m = push({ kind: 'gated_review', feature: 'shaer:externalEmbeds', value: true, ward: KIND });
+  assert.ok(m, 'zonder dit weet een guardian niet dat er op hem gewacht wordt');
+  assert.equal(m.type, 'guardian');
+  assert.match(m.body, /kind/, 'de melding zegt over WIE het gaat');
+  assert.match(m.body, /linkvoorbeelden|link previews|Link-Vorschauen/i, 'en WELKE instelling');
+  assert.equal(m.url, '/guardian');
+});
+
+test('de stand staat erin, aan zowel als uit', () => {
+  const aan = push({ kind: 'gated_review', feature: 'shaer:externalEmbeds', value: true, ward: KIND });
+  const uit = push({ kind: 'gated_review', feature: 'shaer:externalEmbeds', value: false, ward: KIND });
+  assert.notEqual(aan.body, uit.body, 'anders lees je niet of iets AAN of UIT gezet wordt');
+});
+
+test('afspelen is niet hetzelfde als linkvoorbeelden', () => {
+  // Er is meer dan een gate, en ze betekenen heel verschillende dingen voor een
+  // kind. Een melding die dat niet zegt is nutteloos.
+  const a = push({ kind: 'gated_review', feature: 'shaer:externalEmbeds', value: true, ward: KIND });
+  const b = push({ kind: 'gated_review', feature: 'shaer:externalPlayback', value: true, ward: KIND });
+  assert.notEqual(a.body, b.body);
+});
+
+test('een besluit draagt de uitkomst', () => {
+  const af = push({ kind: 'gated_outcome', feature: 'shaer:externalPlayback', value: false, outcome: 'rejected', ward: KIND });
+  const aan = push({ kind: 'gated_outcome', feature: 'shaer:externalPlayback', value: false, outcome: 'accepted', ward: KIND });
+  assert.match(af.body, /afgewezen|rejected|abgelehnt/i);
+  assert.notEqual(af.body, aan.body);
+});
+
+test('een onbekende feature laat de melding NIET vervallen', () => {
+  // Liever een iets vager bericht dan geen bericht: een guardian die niets hoort
+  // denkt dat er niets speelt.
+  assert.ok(push({ kind: 'gated_review', feature: 'shaer:ietsNieuws', value: true, ward: KIND }));
+});
+
+test('een soort die we niet kennen blijft stil', () => {
+  // De vangnetregel blijft staan: liever geen melding dan een lege.
+  assert.equal(push({ kind: 'iets_heel_anders', ward: KIND }), null);
+});
+
+test('de bestaande soorten blijven werken', () => {
+  // Deze brug is verbouwd; wat er al doorheen ging moet er doorheen blijven gaan.
+  for (const kind of ['offer_received', 'offer_for_ward', 'committed', 'guardian_left', 'coguardian_left']) {
+    const m = push({ kind, ward: KIND, guardian: KIND, candidate: KIND });
+    assert.ok(m && m.title && m.body, `${kind} hoort een melding te geven`);
+  }
+});
Index: test/guardian-help-state.test.js
===================================================================
--- test/guardian-help-state.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ test/guardian-help-state.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,115 @@
+// Oppikken en afhandelen van een hulpvraag (shaer-lgo).
+//
+// Een hulpvraag gaat naar ALLE guardians van een kind, op verschillende servers.
+// Zonder gedeelde staat denken er twee dat de ander het oppakt -- en dat is
+// precies het scenario waar de reddingsboei voor bestaat.
+//
+// DE FAALSTAND IS HIER NIET VEILIG. Bij een gate is 'dicht' het veilige
+// antwoord; hier is de faalstand 'iedereen denkt dat het geregeld is', en dat is
+// gevaarlijker dan geen markering. Vandaar dat de meeste tests hieronder gaan
+// over wat er gebeurt als er IETS niet klopt: dan hoort de hulpvraag OPEN te
+// staan.
+//
+// Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const help = await import('../src/services/guardianship/help.js');
+
+const NOTE = 'https://kind.test/ap/notes/hulp-1';
+const OMA = 'https://oma.test/ap/users/oma';
+const OPA = 'https://opa.test/ap/users/opa';
+
+test('een verse hulpvraag staat open', () => {
+  const s = help.statusOf(NOTE);
+  assert.equal(s.open, true);
+  assert.deepEqual(s.pickedUpBy, []);
+  assert.equal(s.handled, null);
+});
+
+test('oppikken mag STAPELEN', () => {
+  // Twee mensen die tegelijk reageren op een kind dat om hulp vraagt is geen
+  // probleem. Twee die allebei niets doen omdat de ander het geclaimd had, wel.
+  help.record(NOTE, OMA, 'pickup', '@oma@oma.test');
+  help.record(NOTE, OPA, 'pickup', '@opa@opa.test');
+  const s = help.statusOf(NOTE);
+  assert.equal(s.pickedUpBy.length, 2);
+  assert.equal(s.open, true, 'opgepikt is nog niet afgehandeld');
+});
+
+test('en het staat erbij DOOR WIE, want dat was de hele vraag', () => {
+  const s = help.statusOf(NOTE);
+  assert.ok(s.pickedUpBy.some((p) => p.handle === '@oma@oma.test'));
+  assert.ok(s.pickedUpBy.every((p) => p.at), 'met een tijdstip, anders kun je niet zien dat het oud wordt');
+});
+
+test('twee keer hetzelfde oppikken telt een keer', () => {
+  help.record(NOTE, OMA, 'pickup', '@oma@oma.test');
+  assert.equal(help.statusOf(NOTE).pickedUpBy.length, 2);
+});
+
+test('een oppik VERVALT NIET maar veroudert wel zichtbaar', () => {
+  // Barts keuze: niets verdwijnt, want een signaal dat wegvalt laat een
+  // hulpvraag er onaangeroerd uitzien terwijl er iemand mee bezig is. Het scherm
+  // toont in plaats daarvan hoe oud het is.
+  const rijen = [{ kind: 'pickup', guardian_uri: OMA, created_at: '2026-08-05T10:00:00.000Z' }];
+  const s = help.helpStatus(rijen, Date.parse('2026-08-07T10:00:00.000Z'));
+  assert.equal(s.open, true);
+  assert.equal(s.ageMs, 2 * 24 * 3600 * 1000, 'twee dagen oud, en dat hoort te zien te zijn');
+});
+
+test('afhandelen sluit hem, met naam', () => {
+  help.record(NOTE, OPA, 'handled', '@opa@opa.test');
+  const s = help.statusOf(NOTE);
+  assert.equal(s.open, false);
+  assert.equal(s.handled.handle, '@opa@opa.test');
+  assert.equal(s.ageMs, null, 'de leeftijd van een oppik zegt niets meer als het klaar is');
+});
+
+test('er is GEEN terugdraai', () => {
+  // Besluit van Bart: sluiten gebeurt met een stevige bevestiging, en leeft de
+  // vraag daarna nog, dan wordt hij OPNIEUW GESTELD -- een nieuwe hulpvraag. Er
+  // wordt niets herschreven.
+  assert.equal(typeof help.default.undo, 'undefined');
+  assert.equal(typeof help.default.reopen, 'undefined');
+});
+
+test('bij twijfel staat een hulpvraag OPEN', () => {
+  // Alles wat geen expliciete afsluiting is telt als open. Een lege lijst, rommel,
+  // een onbekende soort: allemaal 'er wacht nog iemand'. De omgekeerde fout --
+  // iets als afgehandeld tonen dat het niet is -- is de gevaarlijke.
+  for (const rijen of [[], null, [{ kind: 'iets', guardian_uri: OMA }], [{ kind: 'pickup', guardian_uri: OMA }]]) {
+    assert.equal(help.helpStatus(rijen).open, true);
+  }
+});
+
+test('een markering wordt herkend, en iets anders niet', () => {
+  assert.deepEqual(help.parseMarker({ 'shaer:helpPickup': NOTE }), { kind: 'pickup', noteUri: NOTE });
+  assert.deepEqual(help.parseMarker({ 'shaer:helpHandled': NOTE }), { kind: 'handled', noteUri: NOTE });
+  assert.equal(help.parseMarker({ content: '<p>hoi</p>' }), null);
+  assert.equal(help.parseMarker(null), null);
+  assert.equal(help.parseMarker({ 'shaer:helpPickup': '' }), null, 'leeg is geen markering');
+});
+
+test('afhandelen wint van oppikken in hetzelfde bericht', () => {
+  // Kan niet ontstaan uit onze eigen verzending, maar wel uit een andere
+  // implementatie. Het zwaarste signaal telt.
+  assert.equal(help.parseMarker({ 'shaer:helpPickup': NOTE, 'shaer:helpHandled': NOTE }).kind, 'handled');
+});
+
+test('een lijst hulpvragen kost EEN query', () => {
+  const N2 = 'https://kind.test/ap/notes/hulp-2';
+  help.record(N2, OMA, 'pickup', '@oma@oma.test');
+  const m = help.statusFor([NOTE, N2, 'https://kind.test/ap/notes/leeg']);
+  assert.equal(m.size, 3);
+  assert.equal(m.get(NOTE).open, false);
+  assert.equal(m.get(N2).pickedUpBy.length, 1);
+  assert.equal(m.get('https://kind.test/ap/notes/leeg').open, true, 'onbekend is open');
+});
Index: test/interaction-reactions.test.js
===================================================================
--- test/interaction-reactions.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ test/interaction-reactions.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,102 @@
+// shaer-ipb: ap_interactions.acted_* was de DERDE bron van "heb ik hierop
+// gereageerd", naast ap_timeline.liked/boosted en ap_my_reactions. shaer-9e9
+// trok de eerste twee samen en liet deze staan, omdat hij op het interactie-rij-
+// id gesleuteld was en de tussentabel op de object-URI.
+//
+// Nu is ook deze bron de tussentabel. Wat hier bewaakt wordt:
+//   - de migratie neemt bestaande acted_*-rijen mee
+//   - getInteractions leest uit de tussentabel, niet meer uit de kolom
+//   - dezelfde note die je als comment EN als post kent, is EEN feit
+//
+// Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = 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');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Me', 'u1');
+db.prepare('INSERT INTO posts (id, site_id, slug, author_id, title, content, status) VALUES (?,?,?,?,?,?,?)')
+  .run('p1', 's1', 'mijn-post', 'u1', 'Mijn post', '<p>x</p>', 'published');
+
+const site = { slug: 'me', title: 'Me' };
+const COMMENT = 'https://203.0.113.60/notes/comment-1';
+const COMMENT2 = 'https://203.0.113.60/notes/comment-2';
+
+function comment(uri, extra = {}) {
+  db.prepare(`INSERT INTO ap_interactions (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, content, acted_like, acted_boost)
+              VALUES ('reply', 'p1', ?, ?, 'Anna', '@anna@203.0.113.60', '<p>hoi</p>', ?, ?)`)
+    .run(uri, 'https://203.0.113.60/users/anna', extra.like ? 1 : 0, extra.boost ? 1 : 0);
+  return db.prepare('SELECT id FROM ap_interactions WHERE object_uri = ?').get(uri).id;
+}
+
+const knop = (uri) => {
+  const plat = [];
+  const loop = (ns) => ns.forEach((n) => { plat.push(n); loop(n.children || []); });
+  loop(AP.getInteractions('p1', 'https://klonkt.test', site).thread);
+  const n = plat.find((x) => x.noteId === uri);
+  assert.ok(n, `node ${uri} hoort in de thread te staan`);
+  return { like: n.acted_like, boost: n.acted_boost };
+};
+
+test('de migratie neemt acted_* mee naar de tussentabel', () => {
+  comment(COMMENT, { like: true });
+  const uit = AP.migrateReactions({ force: true });
+  assert.equal(uit.reacties, 1, 'één reactie hoort overgenomen te worden');
+  assert.deepEqual(AP.getReaction('me', COMMENT), { liked: true, boosted: false });
+});
+
+test('en is idempotent: nog een keer draaien voegt niets toe', () => {
+  const uit = AP.migrateReactions({ force: true });
+  assert.equal(uit.reacties, 0);
+});
+
+test('de knopstand komt uit de tussentabel, niet uit de kolom', () => {
+  // Zet de kolom en de tussentabel EXPLICIET tegenover elkaar. Zou de view nog
+  // uit de kolom lezen, dan zou hier true uit komen.
+  db.prepare('UPDATE ap_interactions SET acted_boost = 1 WHERE object_uri = ?').run(COMMENT);
+  assert.equal(knop(COMMENT).boost, false, 'de kolom liegt en wordt genegeerd');
+  AP.setReaction('me', COMMENT, 'boost', true);
+  assert.equal(knop(COMMENT).boost, true);
+});
+
+test('een reactie geven en intrekken laat niets achter', () => {
+  comment(COMMENT2);
+  assert.deepEqual(knop(COMMENT2), { like: false, boost: false });
+  AP.setReaction('me', COMMENT2, 'like', true);
+  assert.equal(knop(COMMENT2).like, true);
+  AP.setReaction('me', COMMENT2, 'like', false);
+  assert.equal(knop(COMMENT2).like, false);
+});
+
+test('dezelfde note als comment en als post is EEN feit', () => {
+  // De betekeniswijziging van deze bead, expliciet vastgelegd: like je een note
+  // in je Krant, dan staat de knop onder diezelfde note als comment ook aan.
+  // Twee knoppen die los van elkaar aan konden staan voor hetzelfde object was
+  // eerder een bug dan een feature -- er gaat immers één Like de fediverse in.
+  const BEIDE = 'https://203.0.113.60/notes/overlap';
+  comment(BEIDE);
+  db.prepare(`INSERT INTO ap_timeline (id, slug, author_uri, author_name, content, created_at)
+              VALUES (?,?,?,?,?,?)`).run(BEIDE, 'me', 'https://203.0.113.60/users/anna', 'Anna', '<p>x</p>', '2026-08-06 09:00:00');
+  AP.setReaction('me', BEIDE, 'like', true);
+  assert.equal(knop(BEIDE).like, true, 'de like uit de Krant hoort onder de comment te staan');
+});
+
+test('een reactie zonder object_uri blokkeert de migratie niet', () => {
+  // Een like of announce draagt geen object_uri, en fedi-react eist er een --
+  // zulke rijen kunnen dus nooit acted_* dragen. De migratie moet ze overslaan
+  // in plaats van erover te struikelen. De kolom is NOT NULL, dus zo'n rij draagt
+  // een lege string; zo staat hij ook op beta.
+  db.prepare(`INSERT INTO ap_interactions (kind, post_id, object_uri, actor_uri, actor_name, acted_like)
+              VALUES ('like', 'p1', '', ?, 'Bob', 1)`).run('https://203.0.113.60/users/bob');
+  const uit = AP.migrateReactions({ force: true });
+  assert.equal(uit.reacties, 0, 'niets over te nemen, en geen fout');
+});
Index: test/messages-conversations.test.js
===================================================================
--- test/messages-conversations.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ test/messages-conversations.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,162 @@
+// Berichten als gesprekken: antwoorden, mentions en je eigen verzonden
+// berichten vouwen samen tot draden, de rest van de stroom blijft ongemoeid.
+// Dit dekt de groepeerlogica zelf (zuiver, geen DB, geen netwerk); het
+// samenstellen van de stroom zit in getMessages en leunt op de database.
+//
+// Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+
+const dbMod = await import('../src/config/database.js');
+dbMod.initializeDatabase();
+const AP = await import('../src/services/ActivityPubService.js');
+
+const { threadKey, groupConversations } = AP;
+
+// Aflopend gesorteerd, zoals getMessages ze aanlevert.
+const ts = (n) => new Date(Date.UTC(2026, 0, 1, 12, n)).toISOString();
+
+test('threadKey: een post wint van een persoon', () => {
+  assert.equal(threadKey({ type: 'reply', post_slug: 'hallo', handle: '@anna@a.test' }), 'post:hallo');
+  assert.equal(threadKey({ type: 'sent', post_slug: 'hallo', to_handle: '@anna@a.test' }), 'post:hallo');
+  // Zonder post loopt de draad per tegenpartij, hoofdletter- en @-ongevoelig.
+  assert.equal(threadKey({ type: 'mention', handle: '@Anna@A.test' }), 'actor:anna@a.test');
+  assert.equal(threadKey({ type: 'sent', to_handle: 'Anna@a.test' }), 'actor:anna@a.test');
+});
+
+test('threadKey: alles wat geen gesprek is krijgt geen draad', () => {
+  for (const type of ['like', 'announce', 'follow', 'report', 'poll_done']) {
+    assert.equal(threadKey({ type, post_slug: 'hallo' }), null, `${type} hoort geen draad te krijgen`);
+  }
+  assert.equal(threadKey(null), null);
+  assert.equal(threadKey({ type: 'mention' }), null, 'een mention zonder afzender heeft geen sleutel');
+});
+
+test('ontvangen en verzonden op dezelfde post komen in EEN draad', () => {
+  const out = groupConversations([
+    { type: 'sent', post_slug: 'hallo', content: 'graag gedaan', created_at: ts(3) },
+    { type: 'reply', post_slug: 'hallo', post_title: 'Hallo fediverse', handle: '@anna@a.test', name: 'Anna', content: 'dank!', created_at: ts(2) },
+    { type: 'reply', post_slug: 'hallo', post_title: 'Hallo fediverse', handle: '@bo@b.test', name: 'Bo', content: 'mooi', created_at: ts(1) },
+  ]);
+  assert.equal(out.length, 1, 'drie berichten, één draad');
+  const t = out[0];
+  assert.equal(t.type, 'thread');
+  assert.equal(t.count, 3);
+  // De context hoort bij de draad: dit gesprek gaat over een post.
+  assert.deepEqual(t.post, { slug: 'hallo', title: 'Hallo fediverse' });
+  // Een gesprek leest naar beneden: oud → nieuw.
+  assert.deepEqual(t.messages.map((m) => m.content), ['mooi', 'dank!', 'graag gedaan']);
+  // De draad staat op de tijd van zijn NIEUWSTE bericht, zodat een levend
+  // gesprek bovenaan komt.
+  assert.equal(t.created_at, ts(3));
+  // Jij zit er niet bij als deelnemer, de anderen wel.
+  assert.deepEqual(t.people.map((p) => p.name), ['Bo', 'Anna']);
+  assert.equal(t.mine, true);
+});
+
+test('een mention zonder post loopt per persoon, niet per post', () => {
+  const out = groupConversations([
+    { type: 'sent', to_handle: '@cas@c.test', content: 'hoi terug', created_at: ts(2) },
+    { type: 'mention', handle: '@cas@c.test', name: 'Cas', content: 'hoi!', created_at: ts(1) },
+  ]);
+  assert.equal(out.length, 1);
+  assert.equal(out[0].key, 'actor:cas@c.test');
+  assert.equal(out[0].post, null, 'zonder post is er geen context-link');
+  assert.deepEqual(out[0].messages.map((m) => m.content), ['hoi!', 'hoi terug']);
+});
+
+test('twee posts blijven twee draden, op volgorde van hun nieuwste bericht', () => {
+  const out = groupConversations([
+    { type: 'reply', post_slug: 'twee', post_title: 'Twee', handle: '@anna@a.test', created_at: ts(4) },
+    { type: 'reply', post_slug: 'een', post_title: 'Een', handle: '@bo@b.test', created_at: ts(3) },
+    { type: 'reply', post_slug: 'twee', post_title: 'Twee', handle: '@bo@b.test', created_at: ts(2) },
+  ]);
+  assert.deepEqual(out.map((t) => t.key), ['post:twee', 'post:een']);
+  assert.equal(out[0].count, 2);
+  assert.equal(out[1].count, 1);
+});
+
+test('likes, boosts en follows stromen ongemoeid tussen de draden door', () => {
+  const out = groupConversations([
+    { type: 'like', post_slug: 'hallo', handle: '@anna@a.test', created_at: ts(4) },
+    { type: 'reply', post_slug: 'hallo', post_title: 'Hallo', handle: '@bo@b.test', created_at: ts(3) },
+    { type: 'follow', handle: '@cas@c.test', created_at: ts(2) },
+    { type: 'report', handle: '@dee@d.test', created_at: ts(1) },
+  ]);
+  assert.deepEqual(out.map((i) => i.type), ['like', 'thread', 'follow', 'report']);
+  // Een like op dezelfde post trekt die post NIET de draad in: het is geen
+  // gesprek, en Activiteit blijft zijn eigen chip houden.
+  assert.equal(out[0].post_slug, 'hallo');
+});
+
+test('de titel komt van welk bericht in de draad hem ook maar kent', () => {
+  // Een verzonden antwoord kent alleen de slug; een ontvangen antwoord de titel.
+  const out = groupConversations([
+    { type: 'sent', post_slug: 'hallo', created_at: ts(2) },
+    { type: 'reply', post_slug: 'hallo', post_title: 'Hallo fediverse', handle: '@anna@a.test', created_at: ts(1) },
+  ]);
+  assert.deepEqual(out[0].post, { slug: 'hallo', title: 'Hallo fediverse' });
+});
+
+test('lege en rommelige invoer levert geen kapotte draden op', () => {
+  assert.deepEqual(groupConversations([]), []);
+  assert.deepEqual(groupConversations(null), []);
+  // Een gesprekssoort zonder sleutel blijft een losse regel in plaats van te
+  // verdwijnen: nooit een bericht kwijtraken in de groepering.
+  const out = groupConversations([{ type: 'sent', content: 'aan niemand', created_at: ts(1) }]);
+  assert.equal(out.length, 1);
+  assert.equal(out[0].type, 'sent');
+});
+
+test('een direct bericht zonder to_handle valt terug op to_actors', () => {
+  // Precies het geval waarin het het onlogischst is dat er geen draad ontstaat:
+  // een gesprek dat JIJ begon. Zonder deze terugval bleef het een losse regel.
+  const key = threadKey({
+    type: 'sent',
+    to_actors: JSON.stringify(['https://a.test/users/anna', 'https://b.test/users/bo']),
+    created_at: ts(1),
+  });
+  assert.equal(key, 'actor:anna@a.test');
+  // Rommel in de kolom mag niets omgooien.
+  assert.equal(threadKey({ type: 'sent', to_actors: 'geen json' }), null);
+  assert.equal(threadKey({ type: 'sent', to_actors: '[]' }), null);
+});
+
+test('to_handle wint van to_actors, zodat een draad niet splitst', () => {
+  const a = threadKey({ type: 'sent', to_handle: '@anna@a.test' });
+  const b = threadKey({ type: 'sent', to_handle: '@anna@a.test', to_actors: JSON.stringify(['https://b.test/users/bo']) });
+  assert.equal(a, b, 'dezelfde tegenpartij hoort dezelfde sleutel te geven');
+});
+
+test('een eigen bericht met media rendert als een post, niet als kale tekst', async () => {
+  // note-body (gedeeld met de Krant) leest media_json met een `type`; ap_outbox
+  // bewaart attachments met een `mediaType`. Zonder vertaling viel een foto die
+  // JIJ meestuurde weg.
+  const db = (await import('../src/config/database.js')).default;
+  db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+    .run('mu', 'mu', 'mu@test', 'x', 'god');
+  db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('ms', 'media', 'Media', 'mu');
+  db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, to_handle, content, attachments, created_at)
+    VALUES ('om1','media','pm1','een-post','@anna@a.test','<p>kijk</p>',?,'2026-08-06 09:00:00')`)
+    .run(JSON.stringify([{ url: '/media/foto.png', mediaType: 'image/png', name: 'foto' }]));
+
+  const t = AP.getMessages('media', 50, 0).find((i) => i.type === 'thread');
+  const sent = t.messages.find((m) => m.type === 'sent');
+  assert.deepEqual(JSON.parse(sent.media_json), [{ url: '/media/foto.png', type: 'image/png', name: 'foto' }]);
+});
+
+test('een eigen bericht zonder of met kapotte media houdt gewoon zijn tekst', async () => {
+  const db = (await import('../src/config/database.js')).default;
+  db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('ms2', 'media2', 'M2', 'mu');
+  db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, to_handle, content, attachments, created_at)
+    VALUES ('om2','media2','pm2','p','@anna@a.test','<p>a</p>','geen json','2026-08-06 09:00:00'),
+           ('om3','media2','pm3','p','@bo@b.test','<p>b</p>',NULL,'2026-08-06 09:01:00')`).run();
+  const threads = AP.getMessages('media2', 50, 0).filter((i) => i.type === 'thread');
+  const alle = threads.flatMap((t) => t.messages);
+  assert.equal(alle.length, 2, 'geen bericht mag verdwijnen door kapotte media');
+  for (const m of alle) assert.equal(m.media_json, null);
+});
Index: test/messages.test.js
===================================================================
--- test/messages.test.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ test/messages.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -2,4 +2,8 @@
 // in één stroom, groepeert opeenvolgende likes/boosts op dezelfde post, en geeft
 // visibility door (voor de privé-badge). Besluit Robin+Bart 2026-07-16.
+//
+// Sinds Berichten gesprekken toont vouwen antwoorden, mentions en je eigen
+// verzonden berichten samen tot draden (zie messages-conversations.test.js voor
+// de groepeerlogica zelf); likes, boosts en follows blijven losse regels.
 //
 // Run: npm test   (= node --test)
@@ -42,10 +46,16 @@
 const msgs = AP.getMessages('me', 50);
 
-test('eigen outbound reply zit als "sent" in de stroom (nieuwste eerst)', () => {
-  const sent = msgs.find((m) => m.type === 'sent');
+test('eigen outbound reply en het ontvangen antwoord staan in EEN draad', () => {
+  const thread = msgs.find((m) => m.type === 'thread');
+  assert.ok(thread, 'draad ontbreekt');
+  assert.equal(msgs[0], thread, 'de draad met het nieuwste bericht hoort bovenaan');
+  const sent = thread.messages.find((m) => m.type === 'sent');
   assert.ok(sent, 'sent-item ontbreekt');
   assert.equal(sent.outboxId, 'out1');
   assert.equal(sent.to_handle, '@dana@r.test');
-  assert.equal(msgs[0].type, 'sent', 'nieuwste item hoort bovenaan');
+  // Hier gaat het om: Dana's antwoord en het jouwe zaten in aparte chips
+  // (Gesprekken en Verzonden) en staan nu op volgorde in dezelfde draad.
+  assert.deepEqual(thread.messages.map((m) => m.type), ['reply', 'sent']);
+  assert.equal(thread.post.slug, 'mijn-post');
 });
 
@@ -58,8 +68,11 @@
 
 test('privé-reply draagt visibility voor de badge en heeft post-context', () => {
-  const reply = msgs.find((m) => m.type === 'reply');
+  const thread = msgs.find((m) => m.type === 'thread');
+  const reply = thread.messages.find((m) => m.type === 'reply');
   assert.equal(reply.visibility, 'direct');
   assert.equal(reply.post_slug, 'mijn-post');
   assert.equal(reply.post_title, 'Mijn post');
+  // De context hangt ook aan de draad zelf: die voedt de link in de kop.
+  assert.deepEqual(thread.post, { slug: 'mijn-post', title: 'Mijn post' });
 });
 
Index: test/reactions-characterization.test.js
===================================================================
--- test/reactions-characterization.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ test/reactions-characterization.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,420 @@
+// KARAKTERISERING van "heb ik hierop gereageerd", vóór de opschoning van
+// shaer-9e9. Deze tests beschrijven wat de code NU doet, niet wat ze zou moeten
+// doen. Ze staan hier om de aanstaande refactor hoorbaar te maken.
+//
+// Waarom dat nodig is: dit gebied faalt stil. Elke mark*-aanroep zit in een
+// try/catch die niets doet, en een UPDATE die geen rij raakt is geen fout. Zo
+// kon de shaer:liked-bug (Klonkt 04aca12) maanden bestaan. Een refactor kan hier
+// dus slagen, groen testen en tóch state kwijtraken -- tenzij het huidige gedrag
+// eerst is vastgeschreven.
+//
+// LET OP: de tests onder "de twee bronnen lopen uiteen" roepen de PRIMITIEVEN
+// aan (markLiked, setMyReaction). Die blijven bewust gescheiden -- het
+// samenvoegen zit in setReaction, dus in de aanroepers, niet in de primitieven.
+// Deze tests blijven daarom groen na fase 1 en beschrijven dan nog steeds iets
+// waars: wie markLiked los aanroept, raakt de tussentabel niet. Dat is precies
+// waarom setReaction bestaat en waarom die primitieven op termijn intern moeten
+// worden.
+//
+// De invariant die fase 1 wél bewaakt staat onderaan, bij setReaction.
+//
+// Wat hier NIET in kan: de routes zelf (die vragen HTTP + sessie) en het
+// C2S-pad voor Like/Announce (dat doet netwerk; zie de kop van
+// c2s-outbox.test.js). Dit dekt de servicelaag waar alle drie de ingangen op
+// uitkomen.
+//
+// Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = 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');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Me', 'u1');
+
+const uri = (n) => `https://r.test/ap/notes/${n}`;
+
+/** Een post van iemand anders in JOUW tijdlijn-cache. */
+function seedTimeline(id, author = 'https://r.test/users/anna') {
+  db.prepare(`INSERT OR IGNORE INTO ap_timeline (id, slug, author_uri, author_name, content, created_at)
+              VALUES (?,?,?,?,?,?)`).run(id, 'me', author, 'Anna', '<p>x</p>', '2026-08-06 09:00:00');
+}
+
+// ── Ingang 1: de tijdlijn-route (posts.js ~1279-1304) ────────────────────
+// Schrijft de KOLOM en leest de kolom. De tussentabel blijft ongemoeid.
+
+test('tijdlijn-route: markLiked zet de kolom en getTimelineReaction leest hem', () => {
+  const u = uri('t1'); seedTimeline(u);
+  assert.deepEqual(AP.getTimelineReaction('me', u), { liked: false, boosted: false });
+  AP.markLiked('me', u);
+  assert.deepEqual(AP.getTimelineReaction('me', u), { liked: true, boosted: false });
+  AP.unmarkLiked('me', u);
+  assert.equal(AP.getTimelineReaction('me', u).liked, false);
+});
+
+test('tijdlijn-route: markBoosted zet de kolom, maar de Cirkel volgt de tussentabel', () => {
+  // BIJGEWERKT toen de weergave werd gelijkgetrokken. Vroeger trok een kale
+  // kolomvlag de post de Cirkel in; nu selecteert de Cirkel op de tussentabel,
+  // dus de primitief alleen is niet meer genoeg. Dat is het doel: tekenen en
+  // beslissen leunen op dezelfde bron.
+  const u = uri('t2'); seedTimeline(u);
+  AP.markBoosted('me', u);
+  assert.equal(AP.getTimelineReaction('me', u).boosted, true, 'de kolom staat aan');
+  assert.equal(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), false,
+    'maar dat brengt hem niet in de Cirkel');
+  // Via het echte schrijfpad wel.
+  AP.setReaction('me', u, 'boost', true);
+  assert.ok(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), 'via setReaction wel');
+  AP.setReaction('me', u, 'boost', false);
+  assert.equal(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), false);
+});
+
+test('tijdlijn-route: een vlag op een post die NIET in je tijdlijn staat gaat stil verloren', () => {
+  // Dit is het stille falen waar de refactor voor moet oppassen: geen fout,
+  // geen rij, geen spoor. Een UPDATE die niets raakt is voor SQLite in orde.
+  const u = uri('bestaat-niet');
+  AP.markLiked('me', u);
+  assert.deepEqual(AP.getTimelineReaction('me', u), { liked: false, boosted: false });
+});
+
+// ── Ingang 2: de interact-route (posts.js ~841-869) ──────────────────────
+// Schrijft de TUSSENTABEL en leest de tussentabel. De kolom blijft ongemoeid.
+
+test('interact-route: setMyReaction schrijft de tussentabel, ook zonder tijdlijnrij', () => {
+  const u = uri('i1');   // bewust NIET in ap_timeline
+  assert.deepEqual(AP.getMyReactions('me', u), { liked: false, boosted: false });
+  AP.setMyReaction('me', u, 'like', true);
+  assert.deepEqual(AP.getMyReactions('me', u), { liked: true, boosted: false });
+  AP.setMyReaction('me', u, 'boost', true);
+  assert.deepEqual(AP.getMyReactions('me', u), { liked: true, boosted: true });
+  AP.setMyReaction('me', u, 'like', false);
+  assert.deepEqual(AP.getMyReactions('me', u), { liked: false, boosted: true });
+});
+
+test('interact-route: tweemaal dezelfde reactie levert geen dubbele rij op', () => {
+  const u = uri('i2');
+  AP.setMyReaction('me', u, 'like', true);
+  AP.setMyReaction('me', u, 'like', true);
+  const n = db.prepare('SELECT COUNT(*) AS n FROM ap_my_reactions WHERE site_slug=? AND target_uri=?').get('me', u).n;
+  assert.equal(n, 1, 'de UNIQUE hoort het dubbel opslaan te voorkomen');
+});
+
+// ── De valkuil met naam: upsertBoostedNote is een INSERT ─────────────────
+
+test('upsertBoostedNote trekt een post die je NIET volgt je tijdlijn in', () => {
+  // Dit is geen vlag zetten. Zonder deze insert bestaat de rij niet, kan de
+  // vlag nergens landen, en verschijnt de boost nergens. Wie dit in de refactor
+  // vervangt door "gewoon markBoosted" boost iets wat daarna onvindbaar is.
+  const u = uri('b1');
+  assert.equal(db.prepare('SELECT COUNT(*) AS n FROM ap_timeline WHERE id=?').get(u).n, 0);
+  AP.upsertBoostedNote('me', {
+    object_uri: u, actor_uri: 'https://r.test/users/bo', actor_name: 'Bo',
+    content: '<p>geboost</p>', media: '[]',
+  });
+  assert.equal(db.prepare('SELECT COUNT(*) AS n FROM ap_timeline WHERE id=?').get(u).n, 1, 'de rij hoort aangemaakt te worden');
+  assert.equal(AP.getTimelineReaction('me', u).boosted, true, 'en meteen als geboost gemarkeerd');
+  // BIJGEWERKT: de Cirkel volgt sinds het gelijktrekken van de weergave de
+  // tussentabel, en die vult alleen setReaction. upsertBoostedNote blijft
+  // verantwoordelijk voor de RIJ -- zonder die insert heeft de reactie nergens
+  // een post om bij te horen.
+  assert.equal(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), false, 'de rij alleen is niet genoeg');
+  AP.setMyReaction('me', u, 'boost', true);
+  assert.ok(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), 'met de tussentabel erbij wel');
+});
+
+// ── De twee bronnen lopen uiteen ─────────────────────────────────────────
+// De primitieven raken elkaar niet, en dat blijft na fase 1 zo: het
+// samenvoegen zit in setReaction. Deze drie leggen vast waarom die functie
+// moet bestaan, en waarom markLiked en broers uiteindelijk intern horen te
+// worden -- zolang ze los aanroepbaar zijn, kan een aanroeper de helft doen.
+
+test('HUIDIG GEDRAG: de tijdlijn-route vult de tussentabel niet', () => {
+  const u = uri('d1'); seedTimeline(u);
+  AP.markLiked('me', u);
+  assert.equal(AP.getTimelineReaction('me', u).liked, true, 'de kolom staat aan');
+  assert.equal(AP.getMyReactions('me', u).liked, false,
+    'de primitief raakt de tussentabel niet -- daarvoor is setReaction');
+});
+
+test('HUIDIG GEDRAG: de interact-route vult de kolom niet', () => {
+  const u = uri('d2'); seedTimeline(u);
+  AP.setMyReaction('me', u, 'like', true);
+  assert.equal(AP.getMyReactions('me', u).liked, true, 'de tussentabel staat aan');
+  assert.equal(AP.getTimelineReaction('me', u).liked, false,
+    'de primitief raakt de kolom niet -- daarvoor is setReaction');
+});
+
+test('HUIDIG GEDRAG: alleen het C2S-pad schrijft allebei', () => {
+  // Wat ingestOutboxActivity sinds Klonkt 04aca12 doet, hier nagebootst zonder
+  // het netwerk: setMyReaction + markLiked. Dit is het enige pad dat de twee
+  // bronnen gelijk houdt, en precies daarom werkt un-liken vanuit een app wel.
+  const u = uri('d3'); seedTimeline(u);
+  AP.setMyReaction('me', u, 'like', true);
+  AP.markLiked('me', u);
+  assert.equal(AP.getMyReactions('me', u).liked, true);
+  assert.equal(AP.getTimelineReaction('me', u).liked, true);
+});
+
+// ── De scheefheid, meetbaar (fase 0 uit shaer-9e9 als test) ─────────────
+
+test('meetbaar: hoeveel rijen hebben een vlag zonder tegenhanger', () => {
+  // Dezelfde query als fase 0 in shaer-9e9, hier als test zodat de refactor
+  // hem kan gebruiken: na fase 1 hoort dit getal 0 te zijn en te blijven.
+  const scheef = db.prepare(`
+    SELECT COUNT(*) AS n FROM ap_timeline t
+     WHERE (t.liked = 1 OR t.boosted = 1)
+       AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
+                        WHERE r.site_slug = t.slug AND r.target_uri = t.id)`).get().n;
+  assert.ok(scheef > 0, 'vandaag lopen ze uiteen; na fase 1 hoort deze assert omgedraaid te worden naar === 0');
+});
+
+// ── De invariant van fase 1: setReaction houdt de bronnen gelijk ─────────
+
+test('setReaction: een like landt in BEIDE bronnen', () => {
+  const u = uri('sr1'); seedTimeline(u);
+  AP.setReaction('me', u, 'like', true);
+  assert.equal(AP.getMyReactions('me', u).liked, true, 'tussentabel');
+  assert.equal(AP.getTimelineReaction('me', u).liked, true, 'afgeleide vlag');
+  AP.setReaction('me', u, 'like', false);
+  assert.equal(AP.getMyReactions('me', u).liked, false);
+  assert.equal(AP.getTimelineReaction('me', u).liked, false);
+});
+
+test('setReaction: een boost met note trekt de post je tijdlijn in EN vult beide', () => {
+  // De valkuil uit het plan: zonder de note bestaat de rij niet en landt de
+  // vlag nergens, dus zou de boost onvindbaar zijn.
+  const u = uri('sr2');
+  AP.setReaction('me', u, 'boost', true, {
+    note: { object_uri: u, actor_uri: 'https://r.test/users/bo', actor_name: 'Bo', content: '<p>x</p>', media: '[]' },
+  });
+  assert.equal(AP.getMyReactions('me', u).boosted, true, 'tussentabel');
+  assert.equal(AP.getTimelineReaction('me', u).boosted, true, 'afgeleide vlag');
+  assert.ok(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), 'en zichtbaar in de Cirkel');
+  AP.setReaction('me', u, 'boost', false);
+  assert.equal(AP.getMyReactions('me', u).boosted, false);
+  assert.equal(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), false);
+});
+
+test('setReaction: flagUri bepaalt de sleutel voor BEIDE bronnen', () => {
+  // Vroeger kreeg de tussentabel de URI die de client stuurde en de vlag de
+  // opgeloste. Nu is het er één, anders is dezelfde like onvindbaar vanaf een
+  // pagina die de andere URI kent.
+  const gestuurd = 'https://r.test/@anna/123';
+  const opgelost = uri('sr3');
+  seedTimeline(opgelost);
+  AP.setReaction('me', gestuurd, 'like', true, { flagUri: opgelost });
+  assert.equal(AP.getReaction('me', opgelost).liked, true, 'onder de opgeloste uri');
+  assert.equal(AP.getTimelineReaction('me', opgelost).liked, true, 'en de vlag ook');
+  assert.equal(AP.getMyReactions('me', gestuurd).liked, false, 'niet meer onder de gestuurde');
+});
+
+test('setReaction: rommelige invoer doet niets in plaats van iets halfs', () => {
+  AP.setReaction('me', uri('sr4'), 'sterretje', true);
+  assert.equal(AP.getMyReactions('me', uri('sr4')).liked, false);
+  AP.setReaction('', uri('sr5'), 'like', true);
+  AP.setReaction('me', '', 'like', true);
+  assert.equal(AP.getMyReactions('me', uri('sr5')).liked, false);
+});
+
+// ── Fase 2: lezen komt uit de tussentabel ────────────────────────────────
+
+test('getReaction leest de tussentabel, niet de kolom', () => {
+  const u = uri('g1'); seedTimeline(u);
+  AP.setReaction('me', u, 'like', true);
+  assert.deepEqual(AP.getReaction('me', u), { liked: true, boosted: false });
+  AP.setReaction('me', u, 'boost', true);
+  assert.deepEqual(AP.getReaction('me', u), { liked: true, boosted: true });
+});
+
+test('WAAROM DE BACKFILL EERST MOET: een kale kolomvlag is onzichtbaar voor getReaction', () => {
+  // Dit is de reden dat scripts/backfill-reactions.mjs tussen fase 1 en 2 hoort.
+  // Een reactie van vóór fase 1 staat alleen in de kolom; zodra de lezers de
+  // tussentabel volgen is die stil verdwenen -- geen fout, geen spoor.
+  const u = uri('g2'); seedTimeline(u);
+  AP.markLiked('me', u);                                  // zoals de oude tijdlijn-route
+  assert.equal(AP.getTimelineReaction('me', u).liked, true, 'de kolom staat aan');
+  assert.equal(AP.getReaction('me', u).liked, false, 'maar het nieuwe leespad ziet hem niet');
+  // Wat de backfill doet:
+  AP.setMyReaction('me', u, 'like', true);
+  assert.equal(AP.getReaction('me', u).liked, true, 'na aanvullen wel');
+});
+
+test('getReactionsFor haalt een hele pagina in één keer op', () => {
+  const a = uri('g3'), b = uri('g4'), c = uri('g5');
+  seedTimeline(a); seedTimeline(b);
+  AP.setReaction('me', a, 'like', true);
+  AP.setReaction('me', b, 'boost', true);
+  const m = AP.getReactionsFor('me', [a, b, c]);
+  assert.equal(m.get(a).liked, true);
+  assert.equal(m.get(b).boosted, true);
+  assert.equal(m.get(c), undefined, 'wie niets heeft komt niet in de map; de aanroeper valt terug op false');
+  // Dezelfde uitkomst als per stuk vragen, zodat de batch geen eigen waarheid wordt.
+  for (const u of [a, b]) assert.deepEqual(m.get(u), AP.getReaction('me', u));
+});
+
+test('getReactionsFor: lege of rommelige invoer geeft een lege map', () => {
+  assert.equal(AP.getReactionsFor('me', []).size, 0);
+  assert.equal(AP.getReactionsFor('me', null).size, 0);
+  assert.equal(AP.getReactionsFor('', [uri('g3')]).size, 0);
+});
+
+// ── Fase 3: het publieke oppervlak is versmald ───────────────────────────
+
+test('de primitieven zijn niet meer bereikbaar via het service-object', async () => {
+  // Routes doen `import ActivityPubService from ...` en werken dus met het
+  // default-object. Zolang markLiked daar in staat, kan een aanroeper de helft
+  // schrijven -- en dat is niet hypothetisch: precies zo bleef shaer:liked
+  // maandenlang false. De named exports blijven bestaan voor intern gebruik en
+  // voor deze tests.
+  const svc = (await import('../src/services/ActivityPubService.js')).default;
+  for (const naam of ['markLiked', 'unmarkLiked', 'markBoosted', 'unmarkBoosted',
+                      'setMyReaction', 'getMyReactions', 'getTimelineReaction']) {
+    assert.equal(svc[naam], undefined, `${naam} hoort niet op het publieke oppervlak te staan`);
+  }
+  // Wat er WEL hoort te staan: het ene schrijfpad en het ene leespad.
+  for (const naam of ['setReaction', 'getReaction', 'getReactionsFor']) {
+    assert.equal(typeof svc[naam], 'function', `${naam} hoort er wel te zijn`);
+  }
+});
+
+// ── De permalink en de object-URI zijn dezelfde post ─────────────────────
+
+test('een like uit de Krant is zichtbaar op de interact-pagina (Robins melding)', () => {
+  // De Krant kent een post als AP-object-URI, de interact-pagina als permalink.
+  // Zochten die twee in verschillende sleutelruimtes, dan toonde een geboost en
+  // geliket bericht daar geen enkele highlight -- terwijl de reactie bestond.
+  const obj = uri('perm1');
+  const permalink = 'https://sound-fabrics.com/effortlesseffect';
+  db.prepare(`INSERT OR IGNORE INTO ap_timeline (id, slug, author_uri, author_name, content, url, created_at)
+              VALUES (?,?,?,?,?,?,?)`)
+    .run(obj, 'me', 'https://r.test/users/anna', 'Anna', '<p>x</p>', permalink, '2026-08-06 09:00:00');
+
+  AP.setReaction('me', obj, 'like', true);      // zoals de Krant het doet
+  AP.setReaction('me', obj, 'boost', true);
+  // en de interact-pagina vraagt het met de permalink:
+  assert.deepEqual(AP.getReaction('me', permalink), { liked: true, boosted: true });
+});
+
+test('andersom net zo: reageren via de permalink landt op de object-URI', () => {
+  const obj = uri('perm2');
+  const permalink = 'https://sound-fabrics.com/tweede';
+  db.prepare(`INSERT OR IGNORE INTO ap_timeline (id, slug, author_uri, author_name, content, url, created_at)
+              VALUES (?,?,?,?,?,?,?)`)
+    .run(obj, 'me', 'https://r.test/users/bo', 'Bo', '<p>y</p>', permalink, '2026-08-06 09:00:00');
+
+  AP.setReaction('me', permalink, 'like', true);   // zoals de interact-pagina het doet
+  assert.equal(AP.getReaction('me', obj).liked, true, 'onder de object-uri opgeslagen');
+  assert.equal(AP.getTimelineReaction('me', obj).liked, true, 'dus de vlag landt ook goed');
+  assert.equal(AP.getReaction('me', permalink).liked, true, 'en blijft vindbaar via de permalink');
+});
+
+test('een reactie op iets buiten je tijdlijn blijft gewoon werken', () => {
+  // Kennen we de post niet, dan is er niets te canoniseren en blijft de invoer
+  // de sleutel. Een like op een vreemde post mag daar niet op stuklopen.
+  const onbekend = 'https://elders.test/notes/xyz';
+  AP.setReaction('me', onbekend, 'like', true);
+  assert.equal(AP.getReaction('me', onbekend).liked, true);
+});
+
+// ── De migratie bij boot ─────────────────────────────────────────────────
+
+test('migrateReactions hersleutelt een permalink-rij naar de object-URI', () => {
+  // De oude interact-route bewaarde de URI waarmee je binnenkwam, en de
+  // bookmarklet geeft de permalink door. Zonder hersleutelen zijn die rijen
+  // wees zodra er op de object-URI gezocht wordt.
+  const obj = uri('mig1');
+  const permalink = 'https://sound-fabrics.com/oud-artikel';
+  db.prepare(`INSERT OR IGNORE INTO ap_timeline (id, slug, author_uri, author_name, content, url, created_at)
+              VALUES (?,?,?,?,?,?,?)`)
+    .run(obj, 'me', 'https://r.test/users/anna', 'Anna', '<p>x</p>', permalink, '2026-08-06 09:00:00');
+  db.prepare("INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind, created_at) VALUES (?,?,?,?)")
+    .run('me', permalink, 'like', '2026-07-01 12:00:00');
+
+  const uitkomst = AP.migrateReactions({ force: true });
+  assert.ok(uitkomst.hersleuteld >= 1);
+  assert.equal(AP.getReaction('me', obj).liked, true, 'nu vindbaar op de object-uri');
+  const rij = db.prepare('SELECT created_at FROM ap_my_reactions WHERE site_slug=? AND target_uri=? AND kind=?').get('me', obj, 'like');
+  assert.equal(rij.created_at, '2026-07-01 12:00:00', 'bij hersleutelen blijft de oorspronkelijke datum staan');
+  const oud = db.prepare('SELECT COUNT(*) AS n FROM ap_my_reactions WHERE target_uri=?').get(permalink).n;
+  assert.equal(oud, 0, 'de permalink-rij is opgeruimd');
+});
+
+test('migrateReactions vult een kale kolomvlag aan', () => {
+  const u = uri('mig2'); seedTimeline(u);
+  AP.markLiked('me', u);                                  // zoals de oude Krant-route
+  assert.equal(AP.getReaction('me', u).liked, false, 'vooraf onzichtbaar');
+  AP.migrateReactions({ force: true });
+  assert.equal(AP.getReaction('me', u).liked, true, 'daarna zichtbaar');
+});
+
+test('migrateReactions is idempotent en respecteert de versievlag', () => {
+  const eerste = AP.migrateReactions({ force: true });
+  const tweede = AP.migrateReactions({ force: true });
+  assert.equal(tweede.hersleuteld, 0, 'niets meer te hersleutelen');
+  assert.equal(tweede.aangevuld, 0, 'niets meer aan te vullen');
+  assert.ok(eerste.hersleuteld >= 0);
+  // De vlag is VERSIEgebonden, en niet aan één vast getal: een oudere vlag laat
+  // de migratie opnieuw lopen -- zo doet een bump als v2 zijn werk -- en een
+  // gelijke of hogere slaat hem over. Een hardgecodeerd versienummer hier zou bij
+  // elke bump omvallen zonder dat er iets stuk is.
+  const vlag = (v) => db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?,?)').run('reactions_migration_version', v);
+  vlag('1');
+  assert.equal(AP.migrateReactions().overgeslagen, false, 'een oudere vlag laat hem opnieuw lopen');
+  vlag('999');
+  assert.equal(AP.migrateReactions().overgeslagen, true, 'een gelijke of hogere vlag slaat over');
+});
+
+test('migrateReactions --dry-run schrijft niets', () => {
+  const u = uri('mig3'); seedTimeline(u);
+  AP.markLiked('me', u);
+  const telling = AP.migrateReactions({ force: true, dryRun: true });
+  assert.ok(telling.aangevuld >= 1, 'hij ziet wel wat er te doen is');
+  assert.equal(AP.getReaction('me', u).liked, false, 'maar heeft niets geschreven');
+});
+
+// ── De weergave leest dezelfde bron als de knop ──────────────────────────
+
+test('getTimeline tekent liked/boosted uit de tussentabel, niet uit de kolom', () => {
+  // De Krant tekende zijn knoppen op ap_timeline.liked/boosted terwijl de
+  // toggle al uit getReaction besliste. Tekenen en beslissen leunden dus op
+  // verschillende bronnen -- ze waren het alleen eens zolang iets ze gelijk
+  // hield. Hier wordt de kolom expres tegengesteld gezet.
+  const u = uri('tl1'); seedTimeline(u);
+  AP.setReaction('me', u, 'like', true);
+  db.prepare('UPDATE ap_timeline SET liked = 0, boosted = 1 WHERE slug = ? AND id = ?').run('me', u);
+
+  const rij = AP.getTimeline('me', 50, 0).find((r) => r.id === u);
+  assert.equal(rij.liked, true, 'de tussentabel zegt geliket, dus de knop hoort aan');
+  assert.equal(rij.boosted, false, 'en de kolom die iets anders beweert telt niet');
+});
+
+test('de Cirkel selecteert op de tussentabel', () => {
+  const u = uri('tl2'); seedTimeline(u);
+  // Kolom aan, tussentabel leeg: hoort NIET in de Cirkel te staan.
+  db.prepare('UPDATE ap_timeline SET boosted = 1 WHERE slug = ? AND id = ?').run('me', u);
+  assert.equal(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), false,
+    'een kale kolomvlag trekt niets meer de Cirkel in');
+  // En andersom: tussentabel aan, kolom uit.
+  db.prepare('UPDATE ap_timeline SET boosted = 0 WHERE slug = ? AND id = ?').run('me', u);
+  AP.setMyReaction('me', u, 'boost', true);
+  const post = AP.getCirkelPosts('me', 60, 0).find((p) => p.id === u);
+  assert.ok(post, 'de tussentabel bepaalt wat er in de Cirkel staat');
+  assert.ok(post.boosted, 'en het veld dat de view tekent komt daar ook vandaan');
+});
+
+test('boostedCount telt de tussentabel, en alleen wat in je tijdlijn staat', () => {
+  const inTl = uri('tl3'), buiten = 'https://elders.test/notes/qqq';
+  seedTimeline(inTl);
+  const voor = AP.boostedCount('me');
+  AP.setMyReaction('me', inTl, 'boost', true);
+  AP.setMyReaction('me', buiten, 'boost', true);
+  assert.equal(AP.boostedCount('me'), voor + 1, 'alleen de post die in je tijdlijn staat telt mee');
+});
Index: test/seen-notes.test.js
===================================================================
--- test/seen-notes.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ test/seen-notes.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,122 @@
+// shaer-e9g: onthoud de URI van antwoorden die we al ontvangen.
+//
+// Antwoorden van accounts die je volgt komen gewoon binnen, ondertekend door de
+// schrijver zelf. Ze horen niet in de Krant (belongsInTimeline weigert alles met
+// een inReplyTo) en werden daarna nergens bewaard. Kwam er later een
+// DOORGESTUURD antwoord op zo'n bericht, dan kende Klonkt de ouder niet en wees
+// het af -- terwijl het die ouder wel degelijk had gehad. Op boiert ging daar 72%
+// van het doorstuurverkeer op stuk.
+//
+// Wat hier bewaakt wordt is vooral de grens: onthouden gebeurt ALLEEN voor
+// schrijvers die je zelf volgt. Zou dat voor iedereen gelden, dan kan een vreemde
+// eerst een bericht neerleggen en daarna met een antwoord daarop de dereference
+// naar een adres van zijn keuze sturen.
+//
+// Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = 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');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Me', 'u1');
+
+// IP-literals: safeFetch slaat de DNS-lookup over, dus offline.
+const GEVOLGD = 'https://203.0.113.10/users/catsalad';     // volgen we
+const VREEMDE = 'https://203.0.113.90/users/onbekend';     // volgen we niet
+const DOORSTUURDER = 'https://203.0.113.20/users/relay';
+
+db.prepare(`INSERT INTO ap_following (slug, actor_uri, handle, status) VALUES (?,?,?,?)`)
+  .run('me', GEVOLGD, '@catsalad@203.0.113.10', 'accepted');
+
+let bron = null;
+const echteFetch = globalThis.fetch;
+globalThis.fetch = async (url) => {
+  const u = String(url);
+  if (bron && bron.id === u) {
+    return new Response(JSON.stringify(bron), { status: 200, headers: { 'content-type': 'application/activity+json' } });
+  }
+  return new Response('not found', { status: 404 });
+};
+
+const req = (body) => ({ body, headers: { signature: 'keyId="x",signature="y"' }, ip: '203.0.113.9' });
+const gezien = (uri) => !!db.prepare('SELECT 1 FROM ap_seen_notes WHERE uri = ?').get(uri);
+
+/** Een gewoon bezorgd antwoord: ondertekend door de schrijver zelf. */
+function bezorgdAntwoord(auteur, id, inReplyTo) {
+  return {
+    type: 'Create',
+    actor: auteur,
+    object: {
+      id, type: 'Note', attributedTo: auteur, inReplyTo,
+      content: '<p>een antwoord midden in een draad</p>',
+      to: ['https://www.w3.org/ns/activitystreams#Public'],
+    },
+  };
+}
+
+test('een antwoord van iemand die we volgen wordt onthouden', async () => {
+  const uri = 'https://203.0.113.10/notes/catsalad-1';
+  const status = await AP.handleInbox(req(bezorgdAntwoord(GEVOLGD, uri, 'https://203.0.113.10/notes/draad-start')), 'me', { id: GEVOLGD });
+  assert.equal(status, 202);
+  assert.ok(gezien(uri), 'de URI hoort bewaard te zijn');
+});
+
+test('maar het verschijnt NIET in de Krant', () => {
+  // Alleen de URI. Er verandert niets aan wat je te zien krijgt -- een antwoord
+  // hoort in zijn draad, niet in de tijdlijn, en dat blijft zo.
+  assert.equal(db.prepare('SELECT COUNT(*) AS n FROM ap_timeline').get().n, 0);
+});
+
+test('een antwoord van een VREEMDE wordt niet onthouden', async () => {
+  const uri = 'https://203.0.113.90/notes/vreemde-1';
+  await AP.handleInbox(req(bezorgdAntwoord(VREEMDE, uri, 'https://203.0.113.90/notes/iets')), 'me', { id: VREEMDE });
+  assert.equal(gezien(uri), false, 'anders kan een vreemde de poort voor zichzelf openzetten');
+});
+
+test('een gewone POST van iemand die we volgen wordt niet als gezien-antwoord bewaard', async () => {
+  // Die gaat naar de Krant en staat daarmee al in ap_timeline; een tweede
+  // administratie zou alleen maar uiteen kunnen lopen.
+  const uri = 'https://203.0.113.10/notes/gewone-post';
+  const act = bezorgdAntwoord(GEVOLGD, uri, null);
+  delete act.object.inReplyTo;
+  await AP.handleInbox(req(act), 'me', { id: GEVOLGD });
+  assert.equal(gezien(uri), false);
+  assert.ok(db.prepare('SELECT 1 FROM ap_timeline WHERE id = ?').get(uri), 'die hoort juist wel in de Krant');
+});
+
+test('en dan komt het doorgestuurde antwoord OP dat bericht wel binnen', async () => {
+  // De hele reden van deze bead. Zonder het onthouden was dit een afwijzing met
+  // "skipped (unknown inReplyTo)".
+  const ouder = 'https://203.0.113.10/notes/catsalad-1';   // hierboven onthouden
+  const antwoord = 'https://203.0.113.30/notes/drinkcoaster-1';
+  const schrijver = 'https://203.0.113.30/users/drinkcoaster';
+  bron = {
+    id: antwoord, type: 'Note', attributedTo: schrijver, inReplyTo: ouder,
+    content: '<p>ECHTE versie</p>', to: ['https://www.w3.org/ns/activitystreams#Public'],
+  };
+  const act = { type: 'Create', actor: schrijver, object: { ...bron, content: '<p>BEZORGDE versie</p>' } };
+  const status = await AP.handleInbox(req(act), 'me', { id: DOORSTUURDER });
+  assert.equal(status, 202, 'de doorgestuurde Create hoort geaccepteerd te worden');
+});
+
+test('een doorgestuurd antwoord op een bericht dat we NIET kenden blijft geweigerd', async () => {
+  const antwoord = 'https://203.0.113.30/notes/drinkcoaster-2';
+  const schrijver = 'https://203.0.113.30/users/drinkcoaster';
+  bron = {
+    id: antwoord, type: 'Note', attributedTo: schrijver, inReplyTo: 'https://203.0.113.90/notes/nooit-gezien',
+    content: '<p>x</p>', to: ['https://www.w3.org/ns/activitystreams#Public'],
+  };
+  const act = { type: 'Create', actor: schrijver, object: bron };
+  assert.equal(await AP.handleInbox(req(act), 'me', { id: DOORSTUURDER }), 401);
+});
+
+test.after(() => { globalThis.fetch = echteFetch; });
Index: test/signature-verification.test.js
===================================================================
--- test/signature-verification.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ test/signature-verification.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,131 @@
+// verifyRequest met een ECHTE handtekening.
+//
+// Dit ontbrak, en dat is hoe het hoort te heten: de hele testsuite maakte
+// nergens een handtekening aan. De inbox-tests injecteren preVerified en slaan
+// verifyRequest dus over; de authorized-fetch-tests raken alleen fetchActor.
+// Ondertussen is het sleutel-ophaalpad op 2026-08-06 twee keer verbouwd
+// (9561d58 ondertekenen bij secure mode, efe5633 onbetekend eerst) zonder één
+// test die aantoont dat verificatie nog SLAAGT.
+//
+// Hier wordt een echt RSA-paar gemaakt, het actor-document met die publieke
+// sleutel geserveerd via de gestubde fetch, en een verzoek ondertekend zoals
+// een fediverse-server het doet.
+//
+// Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import crypto from 'crypto';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = 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');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Me', 'u1');
+
+const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
+  modulusLength: 2048,
+  publicKeyEncoding: { type: 'spki', format: 'pem' },
+  privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
+});
+
+const ACTOR = 'https://203.0.113.70/users/zender';
+const KEY_ID = `${ACTOR}#main-key`;
+// Secure mode: dit actor-document komt alleen op een ondertekend verzoek.
+const SECURE_ACTOR = 'https://203.0.113.80/users/gesloten';
+const SECURE_KEY_ID = `${SECURE_ACTOR}#main-key`;
+
+const actorDoc = (id) => ({
+  id, type: 'Person', preferredUsername: 'zender', inbox: `${id}/inbox`,
+  publicKey: { id: `${id}#main-key`, owner: id, publicKeyPem: publicKey },
+});
+
+const echteFetch = globalThis.fetch;
+globalThis.fetch = async (url, opts = {}) => {
+  const u = String(url);
+  const ondertekend = !!(opts.headers && (opts.headers.Signature || opts.headers.signature));
+  const json = (o) => new Response(JSON.stringify(o), { status: 200, headers: { 'content-type': 'application/activity+json' } });
+  if (u === ACTOR) return json(actorDoc(ACTOR));
+  if (u === SECURE_ACTOR) return ondertekend ? json(actorDoc(SECURE_ACTOR)) : new Response('unauthorized', { status: 401 });
+  return new Response('not found', { status: 404 });
+};
+
+/** Bouwt een verzoek zoals een fediverse-server het aflevert: ondertekend over
+ *  (request-target), host, date en digest — precies wat verifyRequest eist. */
+function ondertekendVerzoek({ keyId = KEY_ID, body = { type: 'Follow', actor: ACTOR }, date = new Date().toUTCString(), key = privateKey } = {}) {
+  const raw = Buffer.from(JSON.stringify(body));
+  const digest = 'SHA-256=' + crypto.createHash('sha256').update(raw).digest('base64');
+  const host = 'klonkt.test';
+  const target = 'post /ap/users/me/inbox';
+  const headers = '(request-target) host date digest';
+  const line = [
+    `(request-target): ${target}`,
+    `host: ${host}`,
+    `date: ${date}`,
+    `digest: ${digest}`,
+  ].join('\n');
+  const sig = crypto.sign('sha256', Buffer.from(line), key).toString('base64');
+  return {
+    method: 'POST',
+    originalUrl: '/ap/users/me/inbox',
+    rawBody: raw,
+    body,
+    ip: '203.0.113.9',
+    headers: {
+      host, date, digest,
+      signature: `keyId="${keyId}",headers="${headers}",signature="${sig}"`,
+    },
+  };
+}
+
+test('een correct ondertekend verzoek verifieert', async () => {
+  const verified = await AP.verifyRequest(ondertekendVerzoek(), 'me');
+  assert.ok(verified, 'verifyRequest hoort de actor terug te geven');
+  assert.equal(verified.id, ACTOR);
+});
+
+test('en ook zonder aangewezen site (de gedeelde inbox)', async () => {
+  const verified = await AP.verifyRequest(ondertekendVerzoek());
+  assert.ok(verified);
+  assert.equal(verified.id, ACTOR);
+});
+
+test('een actor achter authorized fetch verifieert ook', async () => {
+  // De combinatie die vandaag tweemaal is verbouwd: de sleutel zit achter secure
+  // mode, dus de ophaal moet ondertekend worden voordat verificatie kan slagen.
+  const verified = await AP.verifyRequest(ondertekendVerzoek({ keyId: SECURE_KEY_ID }), 'me');
+  assert.ok(verified, 'de sleutel hoort ondertekend opgehaald te worden');
+  assert.equal(verified.id, SECURE_ACTOR);
+});
+
+test('een verdraaid lichaam breekt de handtekening', async () => {
+  const req = ondertekendVerzoek();
+  req.rawBody = Buffer.from(JSON.stringify({ type: 'Follow', actor: 'https://kwaad.test/users/x' }));
+  assert.equal(await AP.verifyRequest(req, 'me'), null);
+});
+
+test('een oude Date wordt geweigerd (replay)', async () => {
+  const oud = new Date(Date.now() - 25 * 60 * 60 * 1000).toUTCString();
+  assert.equal(await AP.verifyRequest(ondertekendVerzoek({ date: oud }), 'me'), null);
+});
+
+test('ondertekend met een ANDERE sleutel dan de actor publiceert → null', async () => {
+  const ander = crypto.generateKeyPairSync('rsa', {
+    modulusLength: 2048,
+    publicKeyEncoding: { type: 'spki', format: 'pem' },
+    privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
+  });
+  assert.equal(await AP.verifyRequest(ondertekendVerzoek({ key: ander.privateKey }), 'me'), null);
+});
+
+test('geen handtekening → null, en geen enkele ophaalpoging', async () => {
+  assert.equal(await AP.verifyRequest({ method: 'POST', originalUrl: '/x', headers: {} }, 'me'), null);
+});
+
+test.after(() => { globalThis.fetch = echteFetch; });
