Index: src/services/CircleFederation.js
===================================================================
--- src/services/CircleFederation.js	(revision 46f3dd6f843397dd4da3f37539315aedb56c20f7)
+++ 	(revision )
@@ -1,184 +1,0 @@
-// CircleFederation.js — publication side of "Circles" (v1).
-//
-// Publishes this instance as an ActivityStreams actor with an Ed25519 key,
-// plus an outbox of public posts. The outbox is signed so that consumers
-// (other Klonkt instances) can verify the origin.
-//
-// v1 = PUBLISH + sign only. Pulling/verifying remote circles
-// (CircleService.sync) comes in a later step. See docs/cirkels-v1-spec.md.
-//
-// (The idea of sticking neatly to existing standards was whispered to us by
-//  a certain Bart. Who he is, where he came from — nobody knows for sure.
-//  He appeared, spoke of ActivityStreams, and was gone.)
-
-import crypto from 'crypto';
-import db from '../config/database.js';
-import { getSetting, setSetting } from './SettingsService.js';
-
-// ── Protocol version (federation) ────────────────────────────
-// KLONKT_PROTO is embedded IN the signed input (see signingInput): an instance
-// not running this proto CANNOT verify our signed outbox, and we cannot verify
-// theirs. Staying current is therefore not a polite check you can patch away,
-// but cryptographically enforced — the only way to participate is to run the
-// same proto (= apply the update). Bump KLONKT_PROTO for every release that
-// touches federation/security, and attach a security fix to each bump →
-// outdated = excluded + insecure.
-// MIN_PROTO = the lowest proto we still federate with.
-export const KLONKT_PROTO = 2;
-export const MIN_PROTO = 2;
-
-function signingInput(proto, body) {
-  return `klonkt/proto/${proto}\n${body}`;
-}
-
-// ── Key management ────────────────────────────────────────────
-// Per-instance Ed25519 keypair, generated once and stored in app_settings.
-// Private = PKCS8 PEM (never served). Public = SPKI DER base64
-// (published in the actor; round-tripped via createPublicKey).
-function getKeys() {
-  let priv = getSetting('circle_privkey_pem', null);
-  let pub = getSetting('circle_pubkey_der_b64', null);
-  if (!priv || !pub) {
-    const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
-    priv = privateKey.export({ type: 'pkcs8', format: 'pem' });
-    pub = publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
-    setSetting('circle_privkey_pem', priv);
-    setSetting('circle_pubkey_der_b64', pub);
-  }
-  return { priv, pub };
-}
-
-export function getPublicKeyB64() {
-  return getKeys().pub;
-}
-
-/** Signs a body string, bound to the protocol version (Ed25519). */
-export function signBody(rawString, proto = KLONKT_PROTO) {
-  const key = crypto.createPrivateKey(getKeys().priv);
-  return crypto.sign(null, Buffer.from(signingInput(proto, rawString), 'utf8'), key).toString('base64');
-}
-
-/** Verifies a body against an SPKI-DER-base64 public key for the given proto.
- *  A proto mismatch = a signing-input mismatch = invalid signature. */
-export function verifyBody(rawString, sigB64, pubDerB64, proto = KLONKT_PROTO) {
-  try {
-    const key = crypto.createPublicKey({
-      key: Buffer.from(pubDerB64, 'base64'), format: 'der', type: 'spki',
-    });
-    return crypto.verify(null, Buffer.from(signingInput(proto, rawString), 'utf8'), key, Buffer.from(sigB64, 'base64'));
-  } catch {
-    return false;
-  }
-}
-
-// ── Helpers ───────────────────────────────────────────────────
-function primarySite() {
-  // Solo: the primary/owner site (oldest) — same choice as resolveSite.
-  return db.prepare('SELECT * FROM sites ORDER BY created_at ASC LIMIT 1').get();
-}
-
-function stripHtml(s) {
-  return String(s || '')
-    .replace(/<[^>]+>/g, ' ')
-    .replace(/\[\[[^\]]*\]\]/g, ' ')   // strip [[playlist:..]] / [[track:..]] / [[album:..]] shortcodes
-    .replace(/\s+/g, ' ')
-    .trim();
-}
-
-// Tags column (JSON array or comma-separated) -> clean string array.
-function parseTags(raw) {
-  if (!raw) return [];
-  if (Array.isArray(raw)) return raw.map((t) => String(t).trim()).filter(Boolean);
-  try { const j = JSON.parse(raw); if (Array.isArray(j)) return j.map((t) => String(t).trim()).filter(Boolean); } catch { /* not JSON */ }
-  return String(raw).split(',').map((t) => t.trim()).filter(Boolean);
-}
-
-function iso(d) {
-  const t = d ? new Date(d) : new Date();
-  return isNaN(t.getTime()) ? new Date().toISOString() : t.toISOString();
-}
-
-function abs(base, u) {
-  if (!u) return u;
-  return /^https?:\/\//.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`;
-}
-
-// allow_circle: a site may appear in other instances' circles. v1 ties this
-// to is_public (a separate explicit flag follows in the admin UX step).
-function allowsCircle(site) {
-  return !!site && site.is_public !== 0 && site.allow_circle !== 0;
-}
-
-// ── Actor ─────────────────────────────────────────────────────
-export function buildActor(base) {
-  const site = primarySite();
-  const id = `${base}/.klonkt/actor.json`;
-  const icon = site && (site.profile_photo || site.og_image_default);
-  return {
-    '@context': ['https://www.w3.org/ns/activitystreams', 'https://schema.org/'],
-    type: 'Person',
-    id,
-    name: site ? (site.profile_name || site.title || 'Klonkt') : 'Klonkt',
-    summary: site ? (site.profile_bio || site.tagline || site.description || '') : '',
-    url: `${base}/`,
-    ...(icon ? { icon: { type: 'Image', url: abs(base, icon) } } : {}),
-    outbox: `${base}/.klonkt/outbox.json`,
-    publicKey: {
-      id: `${id}#key`,
-      owner: id,
-      algorithm: 'ed25519',
-      publicKeyBase64: getPublicKeyB64(),
-    },
-    klonkt: { version: 1, proto: KLONKT_PROTO, allowCircle: allowsCircle(site) },
-  };
-}
-
-// ── Outbox ────────────────────────────────────────────────────
-export function buildOutbox(base) {
-  const site = primarySite();
-  const id = `${base}/.klonkt/outbox.json`;
-  const empty = {
-    '@context': 'https://www.w3.org/ns/activitystreams',
-    type: 'OrderedCollection', id, totalItems: 0, orderedItems: [], klonkt: { proto: KLONKT_PROTO },
-  };
-  if (!allowsCircle(site)) return empty;
-
-  const rows = db.prepare(`
-    SELECT slug, title, excerpt, content, cover_image_url, published_at, created_at, type, tags
-    FROM posts
-    WHERE site_id = ? AND status = 'published'
-      AND (origin_server = 'local' OR origin_server IS NULL)
-    ORDER BY COALESCE(published_at, created_at) DESC
-    LIMIT 50
-  `).all(site.id);
-
-  const orderedItems = rows.map((p) => {
-    const url = `${base}/${p.slug}`;
-    const published = iso(p.published_at || p.created_at);
-    const summary = (p.excerpt || stripHtml(p.content)).slice(0, 500);
-    const tags = parseTags(p.tags).slice(0, 12);
-    return {
-      type: 'Create',
-      id: `${url}#create`,
-      published,
-      actor: `${base}/.klonkt/actor.json`,
-      object: {
-        type: p.type === 'audio' ? 'Audio' : 'Article',
-        id: url,
-        name: p.title || '(zonder titel)',
-        summary,
-        url,
-        published,
-        ...(p.cover_image_url ? { image: { type: 'Image', url: abs(base, p.cover_image_url) } } : {}),
-        // ActivityStreams: tags as Hashtag objects (href points to the source tag page).
-        ...(tags.length ? { tag: tags.map((t) => ({ type: 'Hashtag', name: '#' + String(t).replace(/^#/, ''), href: `${base}/tag/${encodeURIComponent(t)}` })) } : {}),
-      },
-    };
-  });
-
-  return {
-    '@context': 'https://www.w3.org/ns/activitystreams',
-    type: 'OrderedCollection', id, totalItems: orderedItems.length, orderedItems,
-    klonkt: { proto: KLONKT_PROTO },
-  };
-}
Index: src/services/CircleService.js
===================================================================
--- src/services/CircleService.js	(revision 46f3dd6f843397dd4da3f37539315aedb56c20f7)
+++ 	(revision )
@@ -1,230 +1,0 @@
-// CircleService.js — pull side of Circles (v1).
-//
-// Per circle_link: fetches the remote actor + outbox, verifies the Ed25519
-// signature, sanitizes, and caches public posts in remote_actors/remote_posts.
-// READ ONLY from remote; never write. See docs/cirkels-v1-spec.md §5b.
-
-import db from '../config/database.js';
-import { verifyBody, KLONKT_PROTO, MIN_PROTO } from './CircleFederation.js';
-import { getTenancy } from './SettingsService.js';
-
-const FETCH_TIMEOUT_MS = 10000;
-const MAX_BODY_BYTES = 1024 * 1024; // 1 MB
-const MAX_ITEMS = 50;
-
-function stripHtml(s) {
-  return String(s || '')
-    .replace(/<[^>]+>/g, ' ')
-    .replace(/\[\[[^\]]*\]\]/g, ' ')   // strip [[playlist:..]] / [[track:..]] / [[album:..]] shortcodes
-    .replace(/\s+/g, ' ')
-    .trim();
-}
-function iso(d) {
-  const t = d ? new Date(d) : null;
-  return t && !isNaN(t.getTime()) ? t.toISOString() : null;
-}
-function originOf(u) {
-  try { return new URL(u).origin; } catch { return null; }
-}
-function baseOf(remoteUrl) {
-  return String(remoteUrl).replace(/\/+$/, '');
-}
-
-// AS Hashtag array -> comma-separated tag names (without #), sanitized.
-function extractTags(tag) {
-  if (!Array.isArray(tag)) return null;
-  const names = tag
-    .map((t) => String((t && t.name) || '').replace(/^#/, '').trim())
-    .filter(Boolean)
-    .slice(0, 12);
-  return names.length ? names.join(', ') : null;
-}
-
-// Mark a source as outside the circle with a readable reason (no silent failure).
-// Separate 'outdated' status so the admin UI can show a clean "update required"
-// notice instead of a generic error.
-function markOutdated(link, msg) {
-  // Remove cached posts from this source: we can no longer verify or refresh
-  // them (proto mismatch), so they no longer belong in the circle feed.
-  if (link.remote_actor_id) {
-    try { db.prepare('DELETE FROM remote_posts WHERE actor_id = ?').run(link.remote_actor_id); } catch {}
-  }
-  db.prepare("UPDATE circle_links SET status='outdated', last_error=?, last_synced=CURRENT_TIMESTAMP WHERE id=?")
-    .run(String(msg).slice(0, 300), link.id);
-  return { ok: false, outdated: true, link: link.remote_url, error: msg };
-}
-
-// Robust, defensive fetch: https only, timeout, body cap, redirect follow.
-async function fetchText(url) {
-  if (!/^https:\/\//i.test(url)) throw new Error('alleen https toegestaan');
-  const ac = new AbortController();
-  const timer = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS);
-  try {
-    const res = await fetch(url, {
-      signal: ac.signal,
-      redirect: 'follow',
-      headers: {
-        Accept: 'application/activity+json, application/json',
-        // Tell the publisher our proto → they can reject us with 426 if we are too old.
-        'Klonkt-Proto': String(KLONKT_PROTO),
-      },
-    });
-    if (!res.ok) throw new Error(`HTTP ${res.status}`);
-    const buf = Buffer.from(await res.arrayBuffer());
-    if (buf.length > MAX_BODY_BYTES) throw new Error('body too large');
-    return { text: buf.toString('utf8'), headers: res.headers, finalUrl: res.url };
-  } finally {
-    clearTimeout(timer);
-  }
-}
-
-// Lazy prepares — tables only exist after initializeDatabase(); this module is
-// imported before that call, so do not prepare at module level.
-let _stmts = null;
-function stmts() {
-  if (_stmts) return _stmts;
-  _stmts = {
-    upsertActor: db.prepare(`
-      INSERT INTO remote_actors (id, url, name, summary, avatar, public_key, fetched_at)
-      VALUES (@id, @url, @name, @summary, @avatar, @public_key, CURRENT_TIMESTAMP)
-      ON CONFLICT(id) DO UPDATE SET
-        url=excluded.url, name=excluded.name, summary=excluded.summary,
-        avatar=excluded.avatar, public_key=excluded.public_key, fetched_at=CURRENT_TIMESTAMP
-    `),
-    upsertPost: db.prepare(`
-      INSERT INTO remote_posts (id, actor_id, published, title, summary, url, media_json, tags, raw_json, fetched_at)
-      VALUES (@id, @actor_id, @published, @title, @summary, @url, @media_json, @tags, @raw_json, CURRENT_TIMESTAMP)
-      ON CONFLICT(id) DO UPDATE SET
-        published=excluded.published, title=excluded.title, summary=excluded.summary,
-        url=excluded.url, media_json=excluded.media_json, tags=excluded.tags,
-        raw_json=excluded.raw_json, fetched_at=CURRENT_TIMESTAMP
-    `),
-  };
-  return _stmts;
-}
-
-export async function syncOne(link) {
-  const base = baseOf(link.remote_url);
-
-  // 1. Fetch + validate actor
-  const actorUrl = `${base}/.klonkt/actor.json`;
-  const a = await fetchText(actorUrl);
-  let actor;
-  try { actor = JSON.parse(a.text); } catch { throw new Error('actor: ongeldige JSON'); }
-  const actorId = actor.id;
-  const pubKey = actor.publicKey && actor.publicKey.publicKeyBase64;
-  if (!actorId || !pubKey) throw new Error('actor mist id/publicKey');
-  if (originOf(actorId) !== originOf(actorUrl)) throw new Error('actor.id heeft andere origin dan de actor-URL');
-
-  // Protocol version gate. The proto is also embedded in the outbox signing
-  // input, so lying in the (unsigned) actor does not help: a real mismatch
-  // will still fail verification later. This check is mainly for a CLEAR
-  // message + exclusion without silent failure.
-  const remoteProto = Number(actor.klonkt && actor.klonkt.proto) || 1;
-  if (remoteProto > KLONKT_PROTO) {
-    return markOutdated(link,
-      `Deze site draait een nieuwere Klonkt (proto ${remoteProto}); jouw instance is proto ${KLONKT_PROTO}. Werk je eigen Klonkt bij om te blijven federeren.`);
-  }
-  if (remoteProto < MIN_PROTO) {
-    return markOutdated(link,
-      `Draait een oudere Klonkt (proto ${remoteProto}; minimaal ${MIN_PROTO} vereist). Vraag ze te updaten.`);
-  }
-
-  // TOFU: a key change requires explicit re-confirmation (anti-hijack)
-  const existing = db.prepare('SELECT public_key FROM remote_actors WHERE id = ?').get(actorId);
-  if (existing && existing.public_key !== pubKey) {
-    throw new Error('publieke sleutel gewijzigd — herbevestiging vereist (TOFU)');
-  }
-
-  stmts().upsertActor.run({
-    id: actorId,
-    url: actor.url || base,
-    name: actor.name || null,
-    summary: actor.summary || null,
-    avatar: (actor.icon && actor.icon.url) || null,
-    public_key: pubKey,
-  });
-
-  // 2. Fetch outbox + verify signature
-  const outboxUrl = actor.outbox || `${base}/.klonkt/outbox.json`;
-  const o = await fetchText(outboxUrl);
-  const sigHeader = o.headers.get('klonkt-signature') || '';
-  const sig = (sigHeader.match(/ed25519=(.+)\s*$/) || [])[1];
-  if (!sig || !verifyBody(o.text, sig, pubKey, remoteProto)) {
-    throw new Error('outbox-handtekening ongeldig of ontbreekt');
-  }
-  let outbox;
-  try { outbox = JSON.parse(o.text); } catch { throw new Error('outbox: ongeldige JSON'); }
-  const items = Array.isArray(outbox.orderedItems) ? outbox.orderedItems.slice(0, MAX_ITEMS) : [];
-
-  // 3. Sanitize + cache objects (same origin as actor = anti-impersonation)
-  const actorOrigin = originOf(actorId);
-  const seen = new Set();
-  for (const it of items) {
-    const obj = it && it.object;
-    if (!obj || !obj.id) continue;
-    if (originOf(obj.id) !== actorOrigin) continue;
-    const media = [];
-    if (obj.image && obj.image.url) media.push({ type: 'image', url: obj.image.url });
-    if (Array.isArray(obj.attachment)) {
-      for (const att of obj.attachment) {
-        if (att && att.url) media.push({ type: String(att.type || 'link').toLowerCase(), url: att.url, name: att.name, duration: att.duration });
-      }
-    }
-    stmts().upsertPost.run({
-      id: obj.id,
-      actor_id: actorId,
-      published: iso(obj.published || it.published),
-      title: stripHtml(obj.name).slice(0, 300) || '(zonder titel)',
-      summary: stripHtml(obj.summary || obj.content).slice(0, 1000),
-      url: obj.url || obj.id,
-      media_json: media.length ? JSON.stringify(media) : null,
-      tags: extractTags(obj.tag),
-      raw_json: JSON.stringify(obj).slice(0, 20000),
-    });
-    seen.add(obj.id);
-  }
-
-  // 4. Pruning: remove posts that are no longer in the outbox
-  const known = db.prepare('SELECT id FROM remote_posts WHERE actor_id = ?').all(actorId).map((r) => r.id);
-  const stale = known.filter((id) => !seen.has(id));
-  if (stale.length) {
-    const del = db.prepare('DELETE FROM remote_posts WHERE id = ?');
-    db.transaction((ids) => ids.forEach((id) => del.run(id)))(stale);
-  }
-
-  // Automatically adopt the name from the remote actor (no manual entry needed).
-  // COALESCE: if the actor has no name, any existing label is preserved.
-  db.prepare(
-    "UPDATE circle_links SET remote_actor_id=?, label=COALESCE(?, label), last_synced=CURRENT_TIMESTAMP, status='active', last_error=NULL WHERE id=?"
-  ).run(actorId, actor.name || null, link.id);
-
-  return { ok: true, actorId, items: seen.size, pruned: stale.length };
-}
-
-export async function sync() {
-  if (getTenancy() !== 'circle') return { skipped: 'tenancy != circle' };
-  const links = db.prepare("SELECT * FROM circle_links WHERE status != 'paused'").all();
-  const results = [];
-  for (const link of links) {
-    try {
-      results.push(await syncOne(link));
-    } catch (e) {
-      const msg = String((e && e.message) || e).slice(0, 300);
-      db.prepare("UPDATE circle_links SET status='error', last_error=?, last_synced=CURRENT_TIMESTAMP WHERE id=?")
-        .run(msg, link.id);
-      results.push({ ok: false, link: link.remote_url, error: msg });
-    }
-  }
-  return { synced: results.length, results };
-}
-
-let _timer = null;
-/** Periodic background sync (gated on tenancy='circle' inside sync()). */
-export function startCircleSyncLoop(intervalMs = 15 * 60 * 1000) {
-  if (_timer) return;
-  const run = () => { sync().catch((e) => console.error('[cirkels] sync-fout:', e.message)); };
-  setTimeout(run, 30 * 1000); // short delay after boot
-  _timer = setInterval(run, intervalMs);
-  if (_timer.unref) _timer.unref();
-}
Index: src/services/SettingsService.js
===================================================================
--- src/services/SettingsService.js	(revision 46f3dd6f843397dd4da3f37539315aedb56c20f7)
+++ src/services/SettingsService.js	(revision 429d3b0e97f3514be589f2982109dd369e53a631)
@@ -40,12 +40,11 @@
 
 export function getTenancy() {
-  const v = getSetting('tenancy', 'solo');
-  // 'hub' is removed → coerce legacy values to 'solo'.
-  return v === 'circle' ? 'circle' : 'solo';
+  // Tenancy is retired: 'hub' and 'circle' were both removed. Every site is
+  // 'solo'. Cirkels are now an ActivityPub feature (auto-boost), not a mode.
+  return 'solo';
 }
 
-export function setTenancy(mode) {
-  const m = mode === 'circle' ? 'circle' : 'solo';
-  setSetting('tenancy', m);
+export function setTenancy() {
+  setSetting('tenancy', 'solo');
 }
 
