Index: src/services/hub-invite.js
===================================================================
--- src/services/hub-invite.js	(revision 31680c3360308110ee32435ada564ef21f612bce)
+++ src/services/hub-invite.js	(revision 31680c3360308110ee32435ada564ef21f612bce)
@@ -0,0 +1,56 @@
+/**
+ * [Add to HUB] — een klonkt zelf op de Klonkt Hub zetten (Robin, 30-9).
+ *
+ * WAAROM DIT MEER IS DAN EEN LINK. Aanmelden op de hub betekent: de hub
+ * stuurt een Follow. Met de eigenaarspoort aan (standaard) wacht die Follow
+ * dan op de eigenaar -- dezelfde persoon die net op de knop drukte. Zonder dit
+ * stuk zou die dus eerst zijn klonkt aanmelden en daarna zijn eigen aanmelding
+ * moeten goedkeuren op /connect.
+ *
+ * De klik IS de toestemming. Hij geldt kort (een dag), alleen voor een Follow
+ * van de host van de hub, en alleen als die Follow is ondertekend door de hub
+ * zelf. De poort blijft verder precies wat hij was: niemand anders komt er
+ * zonder ja door, en bij een WARD beslissen nog steeds de guardians -- die
+ * poort staat in ap-inbox VOOR deze, en daar komt dit stuk nooit langs.
+ *
+ * De uitnodiging wordt niet opgebruikt bij de eerste Follow maar loopt af. Een
+ * hub die zijn Follow opnieuw stuurt (na een time-out) krijgt anders alsnog
+ * een wachtend verzoek voor iets dat al beslist was.
+ */
+import db from '../config/database.js';
+
+const GELDIG_MS = 24 * 3600 * 1000;
+
+/** De hub waar de knop naartoe wijst. Zelfhosters kunnen een andere kiezen. */
+export function hubUrl() {
+  return String(process.env.KLONKT_HUB_URL || 'https://hub.klonkt.com').replace(/\/+$/, '');
+}
+
+function hubHost() {
+  try { return new URL(hubUrl()).host.toLowerCase(); } catch { return ''; }
+}
+
+function hostOf(uri) {
+  try { return new URL(uri).host.toLowerCase(); } catch { return ''; }
+}
+
+/** Komt dit actor-adres van de hub? */
+export const isHubActor = (uri) => !!uri && hostOf(uri) === hubHost();
+
+/** De eigenaar zegt ja, vooruit: de volgende dag mag een Follow van de hub door. */
+export function invite(siteId) {
+  db.prepare('UPDATE sites SET hub_invite_until = ? WHERE id = ?').run(Date.now() + GELDIG_MS, siteId);
+}
+
+/** Heeft de eigenaar van `slug` de hub uitgenodigd, en is dat nog geldig? */
+export function isInvited(slug, actorUri) {
+  if (!isHubActor(actorUri)) return false;
+  const r = db.prepare('SELECT hub_invite_until FROM sites WHERE slug = ?').get(slug);
+  return !!(r && r.hub_invite_until && r.hub_invite_until > Date.now());
+}
+
+/** Volgt de hub deze klonkt al? Dan hoort er geen knop maar een bevestiging. */
+export function onHub(slug) {
+  const rows = db.prepare('SELECT actor_uri FROM ap_followers WHERE slug = ?').all(slug);
+  return rows.some((r) => isHubActor(r.actor_uri));
+}
