Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision ca260d13a940f7563a540fad1f1945ef28e9160c)
+++ src/routes/posts.js	(revision 31680c3360308110ee32435ada564ef21f612bce)
@@ -21,4 +21,5 @@
 import ActivityPubService from '../services/ActivityPubService.js';
 import * as Guardianship from '../services/guardianship/index.js';
+import * as HubInvite from '../services/hub-invite.js';
 import { premiumUnlocked } from '../services/PatreonService.js';
 import { defaultMinCents as paidDefaultMinCents, patreonUrl as paidPatronUrl } from '../services/PaidPatreonService.js';
@@ -1340,4 +1341,6 @@
     connections, myGuardians, followRequests,
     approveFollowers: !!(site && site.approve_followers),
+    // [Add to HUB]: staat hij er al op, dan een bevestiging in plaats van de knop.
+    hub: site ? { url: HubInvite.hubUrl(), onHub: HubInvite.onHub(site.slug), ward: myGuardians.length > 0 } : null,
     // Na een verhuizing staat de uitgaande kant op slot. Dat hoort te blijken
     // VOORDAT je op een knop drukt, niet daarna uit een foutmelding.
@@ -1369,4 +1372,40 @@
   }
   return res.redirect(`${base}/connect`);
+});
+
+// [Add to HUB] (Robin, 30-9): zet deze klonkt op de Klonkt Hub.
+//
+// De knop stuurt je naar het aanmeldformulier van de hub, vooraf ingevuld met
+// je eigen handle; aanmelden blijft daar een eigen klik. Hier gebeurt het
+// deel dat de hub niet kan: de eigenaar geeft alvast zijn ja voor de Follow
+// die daarop volgt, zodat hij straks niet zijn eigen aanmelding hoeft goed te
+// keuren (zie services/hub-invite.js).
+//
+// Staat er al een verzoek van de hub te wachten -- iemand meldde je eerder
+// aan -- dan is deze klik precies het ja waar dat verzoek op wacht, en hoef je
+// niet langs het formulier. Bij een WARD nooit: daar beslissen de guardians.
+router.post('/connect/add-to-hub', requireSiteManager, async (req, res) => {
+  const site = res.locals.site;
+  const base = res.locals.siteUrlBase || '';
+  if (!site) return res.redirect(`${base}/connect`);
+  if (ActivityPubService.movedLock(site).movedTo) {
+    return res.redirect(`${base}/connect?error=` + encodeURIComponent('Dit account is verhuisd'));
+  }
+  const pub = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  const handle = ActivityPubService.deriveHandle(ActivityPubService.actorId(pub, site.slug)).replace(/^@/, '');
+  const hub = HubInvite.hubUrl();
+  const isWard = Guardianship.listGuardians(site.slug).length > 0;
+
+  if (!isWard) {
+    const wacht = Guardianship.follows.listForWard(site.slug)
+      .find((f) => f.status === 'pending' && HubInvite.isHubActor(f.follower_uri));
+    if (wacht) {
+      await ActivityPubService.acceptGatedFollow(wacht);
+      Guardianship.follows.remove(wacht.id);
+      return res.redirect(303, `${hub}/?klonkt=${encodeURIComponent(handle)}`);
+    }
+  }
+  HubInvite.invite(site.id);
+  return res.redirect(303, `${hub}/?add=${encodeURIComponent(handle)}`);
 });
 
