Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 25f2e64684dce47025b6441af8051a5345dac6ca)
+++ src/services/ActivityPubService.js	(revision 5296349e99c4081d397b4aaac417816eb68eeb48)
@@ -1621,4 +1621,48 @@
 }
 
+/**
+ * Een DOORGESTUURDE activiteit alsnog verifiëren (shaer-s8k).
+ *
+ * Reageert iemand in een thread, dan stuurt de server van de oorspronkelijke
+ * poster die reactie door naar de deelnemers -- en ondertekent met zijn EIGEN
+ * sleutel. De handtekening klopt dan, maar de ondertekenaar is niet de auteur,
+ * dus de gate hieronder wees hem af. Gevolg: reacties van derden kwamen niet
+ * binnen, zonder dat iemand een fout zag.
+ *
+ * Mastodon lost dit op met een LD-Signature over de payload. Dat vraagt
+ * JSON-LD-canonicalisatie; wij doen het lichter en strenger: we geloven de
+ * bezorgde inhoud NIET en halen het object op bij de bron.
+ *
+ * Vier voorwaarden, en geen ervan is optioneel:
+ *
+ *  1. Alleen Create en Update. Een doorgestuurde Delete is per definitie niet te
+ *     dereferencen -- het object is weg -- dus die blijft geweigerd.
+ *  2. De host van de object-id MOET die van de geclaimde actor zijn. Zonder dit
+ *     anker wijst een doorsturer je naar een host die hij zelf beheert, waar
+ *     attributedTo alles kan beweren.
+ *  3. Het OPGEHAALDE object wordt gebruikt, niet de bezorgde payload. Anders
+ *     levert een doorsturer een echt id met verdraaide inhoud.
+ *  4. Mislukt het ophalen, of wijst het object zichzelf niet toe aan de
+ *     geclaimde actor, dan blijft het een weigering. Geen twijfelgeval opslaan.
+ */
+async function dereferenceForwarded(act, claimedActor, type, slugParam) {
+  if (type !== 'Create' && type !== 'Update') return null;
+  const o = act && act.object;
+  const objId = typeof o === 'string' ? o : (o && o.id);
+  if (!objId || typeof objId !== 'string' || !/^https:\/\//i.test(objId)) return null;
+  try {
+    if (new URL(objId).host !== new URL(claimedActor).host) return null;   // ankereis
+  } catch { return null; }
+  // Ondertekend als de ontvangende site wanneer we die kennen; anders anoniem,
+  // wat voor een publieke note volstaat. Beide gaan door safeFetch.
+  const fetched = await signedGetJson(slugParam, objId).catch(() => null);
+  if (!fetched || fetched.id !== objId) return null;   // ook: geen omleiding naar iets anders
+  const attributed = typeof fetched.attributedTo === 'string'
+    ? fetched.attributedTo
+    : (fetched.attributedTo && fetched.attributedTo.id);
+  if (attributed !== claimedActor) return null;
+  return fetched;
+}
+
 // Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
 export async function handleInbox(req, slugParam, preVerified = null) {
@@ -1644,6 +1688,24 @@
   const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer', 'Move'];
   if (GATED.includes(type)) {
-    if (!verified || !claimedActor || verified.id !== claimedActor) {
-      console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, verified ? '(signer mismatch)' : '(unsigned/invalid)');
+    // Een geldige handtekening van iemand anders dan de auteur is doorsturen,
+    // geen vervalsing. Haal het object dan bij de bron op in plaats van het af
+    // te wijzen; lukt dat niet, dan valt het door naar de weigering hieronder.
+    let forwarded = null;
+    if (verified && claimedActor && verified.id !== claimedActor) {
+      forwarded = await dereferenceForwarded(act, claimedActor, type, slugParam).catch(() => null);
+      if (forwarded) {
+        act.object = forwarded;   // de OPGEHAALDE inhoud, niet de bezorgde
+        console.log('[AP] inbox forwarded, geverifieerd bij de bron:', type, claimedActor, 'via', verified.id);
+      }
+    }
+    if (!forwarded && (!verified || !claimedActor || verified.id !== claimedActor)) {
+      // Drie verschillende oorzaken, die eerder allemaal "unsigned/invalid"
+      // heetten: geen handtekening meegestuurd, wel een handtekening maar niet
+      // te verifiëren (meestal een opgeheven account waarvan de sleutel weg is),
+      // of geldig ondertekend door iemand anders.
+      const reden = verified ? '(signer mismatch)'
+        : (req.headers && req.headers.signature) ? '(signature present, unverifiable)'
+        : '(no signature)';
+      console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, reden);
       return 401;
     }
Index: test/forwarded-activities.test.js
===================================================================
--- test/forwarded-activities.test.js	(revision 5296349e99c4081d397b4aaac417816eb68eeb48)
+++ test/forwarded-activities.test.js	(revision 5296349e99c4081d397b4aaac417816eb68eeb48)
@@ -0,0 +1,129 @@
+// 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(u);
+  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 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,
+      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.includes(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, 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, content: '<p>x</p>' };
+  const act = doorgestuurd({ id: 'https://203.0.113.66/notes/1', type: 'Note', attributedTo: AUTEUR, 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.includes(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.after(() => { globalThis.fetch = echteFetch; });
