Index: src/server.js
===================================================================
--- src/server.js	(revision 4ae254a5fe167b4e8ad9f22bbd40480efde820f1)
+++ src/server.js	(revision 31288dcbb27f353ad78d4fa8c0e0b21f9b50bb50)
@@ -70,4 +70,5 @@
 import oauthRoutes from './routes/oauth.js';
 import { apWants, startDeliveryWorker, selfHealTimeline, migrateReactions } from './services/ActivityPubService.js';
+import { startWakeWatch } from './services/wake.js';
 
 // SESSION_SECRET: use the env var if set. Otherwise auto-generate a strong one
@@ -210,4 +211,7 @@
 startScheduler(); // release planning: publish scheduled posts when publish_at is reached
 startDeliveryWorker(); // retry failed fediverse deliveries with backoff
+// Wakker worden na echte afwezigheid (shaer-xzab.4/.5): aankondigen en inhalen.
+// Niet bij een gewone herstart; zie services/wake.js.
+startWakeWatch();
 // Once per REACTIONS_MIGRATION_VERSION bump: reacties naar de tussentabel, onder
 // de canonieke object-URI. Moet VOOR het serveren, want vanaf nu leest de code
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 4ae254a5fe167b4e8ad9f22bbd40480efde820f1)
+++ src/services/ActivityPubService.js	(revision 31288dcbb27f353ad78d4fa8c0e0b21f9b50bb50)
@@ -3199,5 +3199,10 @@
 // history-from-before-you-followed or a delivery that was missed while you were down;
 // reading the outbox is the spec-conform way to catch up. PULL ONLY — sends nothing.
-export async function backfillFromOutbox(slug, actorUri, limit = 20) {
+//
+// `since` (ms of een ISO-datum): alleen wat daarna gepubliceerd is. Voor het
+// inhalen na een afwezigheid (shaer-xzab.4): dan hoort niet de hele eerste
+// pagina erbij, alleen wat er sinds het wegvallen bij kwam. Een post zonder
+// datum telt dan niet mee -- van die weet je niet of hij gemist is.
+export async function backfillFromOutbox(slug, actorUri, limit = 20, { since = null } = {}) {
   try {
     if (!slug || !actorUri) return 0;
@@ -3223,4 +3228,8 @@
       if (o.type && o.type !== 'Note' && o.type !== 'Article' && o.type !== 'Question') continue; // skip boosts/other
       if (o.inReplyTo) continue;                                          // top-level only
+      if (since != null) {
+        const t = Date.parse(o.published || '');
+        if (!Number.isFinite(t) || t < new Date(since).getTime()) continue;
+      }
       const auth = actorUriOf(o.attributedTo);
       if (auth && auth !== actorUri) continue;                            // their OWN posts only
Index: src/services/wake.js
===================================================================
--- src/services/wake.js	(revision 31288dcbb27f353ad78d4fa8c0e0b21f9b50bb50)
+++ src/services/wake.js	(revision 31288dcbb27f353ad78d4fa8c0e0b21f9b50bb50)
@@ -0,0 +1,145 @@
+/**
+ * Wakker worden: een Klonkt die niet altijd aan staat (shaer-xzab.4 en .5).
+ *
+ * Een Klonkt op een Mac thuis slaapt, gaat de trein in, staat een weekend uit.
+ * Bij het terugkomen doet hij twee dingen, en die doen elk iets anders -- dat is
+ * uitgezocht in xzab.6, in de broncode van Mastodon, Akkoma, Pleroma, Misskey
+ * en GoToSocial:
+ *
+ *   AANKONDIGEN (.5): een actor-Update naar de gedeelde inboxes van de volgers.
+ *     Een binnenkomende POST is wat Mastodon, Akkoma en Misskey weer naar ons
+ *     laat bezorgen (Mastodon schrijft een domein af na mislukkingen op zeven
+ *     dagen; Pleroma al na één uitgeputte bezorging, en start bij een
+ *     binnenkomende activiteit een controle van onze /). Ondertekende GETs
+ *     zetten daar niets terug.
+ *   INHALEN (.4): de outboxes van wie je volgt nalopen, vanaf het moment dat we
+ *     wegvielen. Het enige wat gemiste posts terugbrengt: GoToSocial gooit een
+ *     bezorging na een paar minuten weg, Mastodon na twee à drie dagen.
+ *
+ * WAKKER WORDEN HERKENNEN, en niet bij elke herstart (npm herstart Klonkt bij
+ * elke opgeslagen wijziging): een sprong in de wandklok tussen twee ticks, of
+ * het opstarten na langer dan AWAY_MS sinds het laatste teken van leven. Dat
+ * teken staat in app_settings, zodat het een herstart overleeft.
+ */
+
+import db from '../config/database.js';
+import { getSetting, setSetting, apEnabled } from './SettingsService.js';
+
+export const AWAY_MS = 30 * 60 * 1000;            // korter is een herstart of een korte slaap
+export const TICK_MS = 60 * 1000;
+// Inhalen: niet verder terug dan een week, en per account een maximum, zodat
+// een week weg geen storm van verzoeken wordt.
+export const CATCHUP_MAX_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
+export const CATCHUP_PER_ACCOUNT = 40;
+export const CATCHUP_MAX_ACCOUNTS = 200;
+const CATCHUP_GAP_MS = 300;                       // beleefd: na elkaar, met een adempauze
+const ALIVE_KEY = 'last_alive_at';
+
+/**
+ * Is dit wakker worden, en sinds wanneer waren we weg? Puur.
+ * `lastAlive` is het laatste teken van leven (ms), `now` nu. Null als het geen
+ * afwezigheid was (of als er nog nooit een teken was: een eerste start is geen
+ * terugkomst).
+ */
+export function absence(lastAlive, now) {
+  if (!Number.isFinite(lastAlive) || lastAlive <= 0) return null;
+  const away = now - lastAlive;
+  return away >= AWAY_MS ? { since: lastAlive, awayMs: away } : null;
+}
+
+/** Vanaf wanneer inhalen: het wegvallen, maar nooit verder terug dan het venster. */
+export function catchupSince(since, now) {
+  return Math.max(since, now - CATCHUP_MAX_WINDOW_MS);
+}
+
+/**
+ * De wakker-routine zelf, met haar afhankelijkheden van buiten, zodat ze
+ * zonder netwerk te toetsen is. Eerst aankondigen (dan bezorgen de anderen weer
+ * terwijl wij inhalen), dan inhalen, account voor account.
+ */
+export async function runWake({ since, now = Date.now() }, deps) {
+  const { sites, deliverActorUpdate, following, backfill, pause = (ms) => new Promise((r) => setTimeout(r, ms)) } = deps;
+  const from = catchupSince(since, now);
+  let announced = 0, fetched = 0, added = 0;
+  for (const site of sites()) {
+    try { await deliverActorUpdate(site); announced++; } catch { /* een site die niet lukt, houdt de rest niet op */ }
+  }
+  for (const site of sites()) {
+    const accounts = following(site.slug).slice(0, CATCHUP_MAX_ACCOUNTS);
+    for (const actorUri of accounts) {
+      try { added += (await backfill(site.slug, actorUri, CATCHUP_PER_ACCOUNT, { since: from })) || 0; } catch { /* best-effort */ }
+      fetched++;
+      await pause(CATCHUP_GAP_MS);
+    }
+  }
+  return { announced, fetched, added };
+}
+
+// ── Het echte werk: welke sites, wie ze volgen ───────────────────────────────
+
+function realSites() {
+  try { return db.prepare('SELECT * FROM sites').all(); } catch { return []; }
+}
+/** Wie deze site volgt en waar dat rond is: alleen GEACCEPTEERD, want een
+ *  wachtend volgverzoek geeft (nog) geen recht op de besloten posts, en de
+ *  outbox van iemand die nee zei hoort niet in je stroom. */
+export function acceptedFollowing(slug) {
+  try {
+    return db.prepare("SELECT actor_uri FROM ap_following WHERE slug = ? AND status = 'accepted' ORDER BY id").all(slug).map((r) => r.actor_uri);
+  } catch { return []; }
+}
+
+let _waking = false;
+async function wake(since, now) {
+  if (_waking) return;                          // één terugkomst tegelijk
+  _waking = true;
+  try {
+    if (!apEnabled()) return;
+    const AP = await import('./ActivityPubService.js');
+    const r = await runWake({ since, now }, {
+      sites: realSites,
+      deliverActorUpdate: AP.deliverActorUpdate,
+      following: acceptedFollowing,
+      backfill: AP.backfillFromOutbox,
+    });
+    console.log(`[wake] back after ${Math.round((now - since) / 60000)} min: Update from ${r.announced} site(s), caught up ${r.fetched} account(s), +${r.added} post(s)`);
+  } catch (e) {
+    console.warn('[wake]', e && e.message);
+  } finally { _waking = false; }
+}
+
+function readAlive() {
+  const v = Number(getSetting(ALIVE_KEY, ''));
+  return Number.isFinite(v) ? v : null;
+}
+function noteAlive(now) {
+  try { setSetting(ALIVE_KEY, String(now)); } catch { /* een teken minder is geen ramp */ }
+}
+
+let _timer = null;
+let _lastTick = null;
+
+/** Eén tik: is er sinds de vorige tik een gat? Dan wakker worden. Los van de
+ *  timer, zodat het te toetsen is. */
+export function tick(now = Date.now(), onWake = wake) {
+  const gat = absence(_lastTick, now);
+  _lastTick = now;
+  noteAlive(now);
+  if (gat) onWake(gat.since, now);
+  return gat;
+}
+
+/** Bij het opstarten: hoe lang stonden we uit? Daarna elke minuut een tik. */
+export function startWakeWatch(onWake = wake) {
+  if (_timer) return;
+  const now = Date.now();
+  const opStart = absence(readAlive(), now);
+  _lastTick = now;
+  noteAlive(now);
+  if (opStart) onWake(opStart.since, now);
+  _timer = setInterval(() => { tick(Date.now(), onWake); }, TICK_MS);
+  if (_timer.unref) _timer.unref();
+}
+
+/** Voor de toetsen: de tik-staat terugzetten. */
+export function _resetForTests() { _lastTick = null; if (_timer) { clearInterval(_timer); _timer = null; } }
Index: test/wake.test.js
===================================================================
--- test/wake.test.js	(revision 31288dcbb27f353ad78d4fa8c0e0b21f9b50bb50)
+++ test/wake.test.js	(revision 31288dcbb27f353ad78d4fa8c0e0b21f9b50bb50)
@@ -0,0 +1,151 @@
+// Wakker worden (shaer-xzab.4 en .5): een Klonkt die niet altijd aan staat,
+// kondigt zich bij terugkomst aan en haalt in wat hij miste.
+//
+// Wat hier bewaakt wordt:
+//   - wakker worden herkennen: na AWAY_MS stilte, bij het opstarten of als
+//     sprong tussen twee ticks -- en NIET bij een gewone herstart;
+//   - eerst aankondigen (een actor-Update per site), dan inhalen;
+//   - inhalen alleen bij geaccepteerde volgrelaties, vanaf het wegvallen, met
+//     een venster van een week en een maximum per account;
+//   - backfillFromOutbox met `since` neemt alleen wat daarna kwam, tegen een
+//     echte outbox op 127.0.0.1.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import http from 'http';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://test.example';
+
+// Een andere server met een actor en een outbox, vóór het laden van de modules
+// (AP_ALLOW_HOSTS wordt bij het laden gelezen).
+const NU = Date.now();
+const uurGeleden = (u) => new Date(NU - u * 3600 * 1000).toISOString();
+let outboxItems = [];
+const ander = http.createServer((req, res) => {
+  const host = `http://${req.headers.host}`;
+  res.setHeader('Content-Type', 'application/activity+json');
+  if (req.url === '/users/anna') {
+    return res.end(JSON.stringify({ id: `${host}/users/anna`, type: 'Person', preferredUsername: 'anna', inbox: `${host}/users/anna/inbox`, outbox: `${host}/users/anna/outbox` }));
+  }
+  if (req.url === '/users/anna/outbox') {
+    return res.end(JSON.stringify({ type: 'OrderedCollection', orderedItems: outboxItems }));
+  }
+  res.statusCode = 404; res.end();
+});
+await new Promise((r) => ander.listen(0, '127.0.0.1', r));
+const anderHost = `127.0.0.1:${ander.address().port}`;
+process.env.AP_ALLOW_HOSTS = anderHost;
+test.after(() => ander.close());
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+{ const stil = console.log; console.log = () => {}; try { dbMod.initializeDatabase(); } finally { console.log = stil; } }
+const W = await import('../src/services/wake.js');
+const AP = await import('../src/services/ActivityPubService.js');
+const { setSetting } = await import('../src/services/SettingsService.js');
+
+// ── Herkennen ────────────────────────────────────────────────────
+
+test('een herstart is geen terugkomst, een half uur stilte wel', () => {
+  assert.equal(W.absence(null, NU), null, 'een eerste start is geen terugkomst');
+  assert.equal(W.absence(NU - 10_000, NU), null, 'npm herstart na een opgeslagen wijziging');
+  assert.equal(W.absence(NU - W.AWAY_MS + 1000, NU), null);
+  assert.deepEqual(W.absence(NU - W.AWAY_MS, NU), { since: NU - W.AWAY_MS, awayMs: W.AWAY_MS });
+});
+
+test('een sprong tussen twee ticks is wakker worden, gewone ticks niet', () => {
+  W._resetForTests();
+  const gezien = [];
+  const wek = (since, now) => gezien.push({ since, now });
+  W.tick(NU, wek);
+  W.tick(NU + 60_000, wek);
+  assert.deepEqual(gezien, [], 'elke minuut een tik: niets aan de hand');
+  W.tick(NU + 60_000 + 45 * 60_000, wek);           // deksel dicht, 45 minuten
+  assert.deepEqual(gezien, [{ since: NU + 60_000, now: NU + 60_000 + 45 * 60_000 }], 'weg sinds de vorige tik');
+  W._resetForTests();
+});
+
+test('bij het opstarten telt hoe lang we uit stonden, en dat overleeft de herstart', () => {
+  const gezien = [];
+  setSetting('last_alive_at', String(Date.now() - 2 * 3600 * 1000));
+  W._resetForTests();
+  W.startWakeWatch((since) => gezien.push(since));
+  W._resetForTests();
+  assert.equal(gezien.length, 1, 'twee uur uit: wakker worden');
+
+  gezien.length = 0;
+  W.startWakeWatch((since) => gezien.push(since));   // meteen weer: het teken is net gezet
+  W._resetForTests();
+  assert.equal(gezien.length, 0, 'en een herstart vlak daarna niet');
+});
+
+// ── De routine ───────────────────────────────────────────────────
+
+test('eerst aankondigen, dan inhalen, vanaf het wegvallen', async () => {
+  const log = [];
+  const sinds = NU - 3 * 3600 * 1000;
+  const r = await W.runWake({ since: sinds, now: NU }, {
+    sites: () => [{ slug: 'een' }, { slug: 'twee' }],
+    deliverActorUpdate: async (s) => { log.push(`update:${s.slug}`); },
+    following: (slug) => (slug === 'een' ? ['https://a/u/1', 'https://b/u/2'] : []),
+    backfill: async (slug, actor, limit, opts) => { log.push(`inhalen:${slug}:${actor}:${limit}:${opts.since}`); return 2; },
+    pause: async () => {},
+  });
+  assert.deepEqual(log, [
+    'update:een', 'update:twee',
+    `inhalen:een:https://a/u/1:${W.CATCHUP_PER_ACCOUNT}:${sinds}`,
+    `inhalen:een:https://b/u/2:${W.CATCHUP_PER_ACCOUNT}:${sinds}`,
+  ]);
+  assert.deepEqual(r, { announced: 2, fetched: 2, added: 4 });
+});
+
+test('een week weg wordt geen storm: venster en maximum', async () => {
+  assert.equal(W.catchupSince(NU - 30 * 24 * 3600 * 1000, NU), NU - W.CATCHUP_MAX_WINDOW_MS);
+  let gevraagd = 0;
+  await W.runWake({ since: NU - 1, now: NU }, {
+    sites: () => [{ slug: 'veel' }],
+    deliverActorUpdate: async () => {},
+    following: () => Array.from({ length: 500 }, (_, i) => `https://x/u/${i}`),
+    backfill: async () => { gevraagd++; return 0; },
+    pause: async () => {},
+  });
+  assert.equal(gevraagd, W.CATCHUP_MAX_ACCOUNTS);
+});
+
+test('een site die niet lukt, houdt de rest niet op', async () => {
+  const log = [];
+  await W.runWake({ since: NU - 3600_000, now: NU }, {
+    sites: () => [{ slug: 'stuk' }, { slug: 'goed' }],
+    deliverActorUpdate: async (s) => { if (s.slug === 'stuk') throw new Error('kapot'); log.push(s.slug); },
+    following: () => [],
+    backfill: async () => 0,
+    pause: async () => {},
+  });
+  assert.deepEqual(log, ['goed']);
+});
+
+test('alleen geaccepteerde volgrelaties worden ingehaald', () => {
+  db.prepare("INSERT INTO ap_following (slug, actor_uri, status) VALUES ('kind', 'https://a/u/ja', 'accepted'), ('kind', 'https://a/u/wacht', 'pending')").run();
+  assert.deepEqual(W.acceptedFollowing('kind'), ['https://a/u/ja']);
+});
+
+// ── backfillFromOutbox met since ─────────────────────────────────
+
+test('inhalen neemt alleen wat na het wegvallen kwam', async () => {
+  const anna = `http://${anderHost}/users/anna`;
+  const note = (id, published) => ({ type: 'Create', object: { id: `http://${anderHost}/notes/${id}`, type: 'Note', attributedTo: anna, content: `<p>${id}</p>`, ...(published ? { published } : {}) } });
+  outboxItems = [note('vers', uurGeleden(1)), note('oud', uurGeleden(72)), note('zonder-datum', null)];
+  const stil = console.log; console.log = () => {};
+  let added;
+  try { added = await AP.backfillFromOutbox('kind', anna, 40, { since: NU - 2 * 3600 * 1000 }); } finally { console.log = stil; }
+  assert.equal(added, 1);
+  const ids = db.prepare("SELECT id FROM ap_timeline WHERE slug = 'kind'").all().map((r) => r.id);
+  assert.deepEqual(ids, [`http://${anderHost}/notes/vers`]);
+});
+
+test('zonder since gaat inhalen als vroeger', async () => {
+  const anna = `http://${anderHost}/users/anna`;
+  outboxItems = [{ type: 'Note', id: `http://${anderHost}/notes/x1`, attributedTo: anna, content: 'x', published: uurGeleden(500) }];
+  const stil = console.log; console.log = () => {};
+  try { assert.equal(await AP.backfillFromOutbox('ander', anna), 1); } finally { console.log = stil; }
+});
