Index: src/routes/admin-circle.js
===================================================================
--- src/routes/admin-circle.js	(revision 57929aaab80d03a3b7ca89b30a1687c618e5b5e8)
+++ src/routes/admin-circle.js	(revision 57929aaab80d03a3b7ca89b30a1687c618e5b5e8)
@@ -0,0 +1,106 @@
+/**
+ * Admin: Cirkel-beheer (god-only).
+ *   GET  /admin/circle           -> lijst van cirkel-links + status
+ *   POST /admin/circle/add       -> Klonkt-URL toevoegen
+ *   POST /admin/circle/:id/remove
+ *   POST /admin/circle/:id/sync  -> nu verversen (pull + verifieer)
+ *   POST /admin/circle/allow     -> toggle "mag in cirkels van anderen verschijnen"
+ *
+ * Zie docs/cirkels-v1-spec.md §5d.
+ */
+
+import express from 'express';
+import crypto from 'crypto';
+import { renderPage } from '../middleware/render.js';
+import { requireGod } from '../middleware/auth.js';
+import db from '../config/database.js';
+import { getTenancy } from '../services/SettingsService.js';
+import { syncOne } from '../services/CircleService.js';
+
+const router = express.Router();
+
+function primarySite() {
+  return db.prepare('SELECT * FROM sites ORDER BY created_at ASC LIMIT 1').get();
+}
+
+router.get('/', requireGod, (req, res) => {
+  const site = primarySite();
+  const links = site
+    ? db.prepare('SELECT * FROM circle_links WHERE local_site_id = ? ORDER BY added_at DESC').all(site.id)
+    : [];
+  const counts = {};
+  for (const l of links) {
+    counts[l.id] = l.remote_actor_id
+      ? db.prepare('SELECT COUNT(*) AS n FROM remote_posts WHERE actor_id = ?').get(l.remote_actor_id).n
+      : 0;
+  }
+  renderPage(req, res, 'pages/admin-circle', {
+    pageTitle: 'Cirkel',
+    bodyClass: 'on-admin',
+    tenancy: getTenancy(),
+    site,
+    links,
+    counts,
+    allowCircle: site ? site.allow_circle !== 0 : true,
+    success: req.query.success || null,
+    error: req.query.error || null,
+  });
+});
+
+router.post('/add', requireGod, (req, res) => {
+  const site = primarySite();
+  if (!site) return res.redirect('/admin/circle?error=' + encodeURIComponent('Geen site gevonden'));
+  const url = (req.body.remote_url || '').toString().trim().replace(/\/+$/, '');
+  if (!/^https:\/\/[^\s/]+(\/[^\s]*)?$/i.test(url)) {
+    return res.redirect('/admin/circle?error=' + encodeURIComponent('Voer een geldige https-URL in'));
+  }
+  const label = (req.body.label || '').toString().slice(0, 80).trim() || null;
+  try {
+    db.prepare("INSERT INTO circle_links (id, local_site_id, remote_url, label, status) VALUES (?, ?, ?, ?, 'active')")
+      .run(crypto.randomUUID(), site.id, url, label);
+  } catch (e) {
+    return res.redirect('/admin/circle?error=' + encodeURIComponent('Deze site staat al in je cirkel'));
+  }
+  res.redirect('/admin/circle?success=' + encodeURIComponent('Toegevoegd — klik "Verversen" om op te halen'));
+});
+
+router.post('/:id/remove', requireGod, (req, res) => {
+  const link = db.prepare('SELECT * FROM circle_links WHERE id = ?').get(req.params.id);
+  if (link) {
+    db.prepare('DELETE FROM circle_links WHERE id = ?').run(link.id);
+    // Gecachte content opruimen als geen andere link nog naar deze actor wijst.
+    if (link.remote_actor_id) {
+      const other = db.prepare('SELECT 1 FROM circle_links WHERE remote_actor_id = ? LIMIT 1').get(link.remote_actor_id);
+      if (!other) {
+        db.prepare('DELETE FROM remote_posts WHERE actor_id = ?').run(link.remote_actor_id);
+        db.prepare('DELETE FROM remote_actors WHERE id = ?').run(link.remote_actor_id);
+      }
+    }
+  }
+  res.redirect('/admin/circle?success=' + encodeURIComponent('Verwijderd'));
+});
+
+router.post('/:id/sync', requireGod, async (req, res) => {
+  const link = db.prepare('SELECT * FROM circle_links WHERE id = ?').get(req.params.id);
+  if (!link) return res.redirect('/admin/circle?error=' + encodeURIComponent('Niet gevonden'));
+  try {
+    const r = await syncOne(link);
+    res.redirect('/admin/circle?success=' + encodeURIComponent(`Bijgewerkt — ${r.items} posts opgehaald`));
+  } 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);
+    res.redirect('/admin/circle?error=' + encodeURIComponent(msg));
+  }
+});
+
+router.post('/allow', requireGod, (req, res) => {
+  const site = primarySite();
+  if (site) {
+    const v = (req.body.allow_circle === 'on' || req.body.allow_circle === '1') ? 1 : 0;
+    db.prepare('UPDATE sites SET allow_circle = ? WHERE id = ?').run(v, site.id);
+  }
+  res.redirect('/admin/circle?success=' + encodeURIComponent('Opgeslagen'));
+});
+
+export default router;
Index: src/routes/admin-settings.js
===================================================================
--- src/routes/admin-settings.js	(revision 63edb0d519500782be884d350a3d2a41569553c2)
+++ src/routes/admin-settings.js	(revision 57929aaab80d03a3b7ca89b30a1687c618e5b5e8)
@@ -88,5 +88,5 @@
 
     if (typeof req.body.tenancy !== 'undefined') {
-      setTenancy(req.body.tenancy === 'hub' ? 'hub' : 'solo');
+      setTenancy(req.body.tenancy); // valideert naar solo | hub | circle
     }
     if (typeof req.body.hub_title !== 'undefined') {
Index: src/routes/circle.js
===================================================================
--- src/routes/circle.js	(revision 57929aaab80d03a3b7ca89b30a1687c618e5b5e8)
+++ src/routes/circle.js	(revision 57929aaab80d03a3b7ca89b30a1687c618e5b5e8)
@@ -0,0 +1,55 @@
+/**
+ * Publieke Cirkel-feed: /cirkel
+ * Toont de gecachte publieke posts uit je cirkel als statische kaarten.
+ * Alleen actief als tenancy === 'circle' (anders next() -> postsRoutes/404).
+ * Zie docs/cirkels-v1-spec.md §5c.
+ */
+
+import express from 'express';
+import { renderPage } from '../middleware/render.js';
+import db from '../config/database.js';
+import { getTenancy } from '../services/SettingsService.js';
+
+const router = express.Router();
+
+function safeUrl(u) {
+  return typeof u === 'string' && /^https?:\/\//i.test(u) ? u : null;
+}
+function safeJson(s) {
+  try { return s ? JSON.parse(s) : []; } catch { return []; }
+}
+
+router.get('/cirkel', (req, res, next) => {
+  if (getTenancy() !== 'circle') return next();
+
+  const rows = db.prepare(`
+    SELECT p.id, p.published, p.title, p.summary, p.url, p.media_json,
+           a.name AS actor_name, a.url AS actor_url, a.avatar AS actor_avatar
+    FROM remote_posts p
+    JOIN remote_actors a ON a.id = p.actor_id
+    ORDER BY COALESCE(p.published, p.fetched_at) DESC
+    LIMIT 100
+  `).all();
+
+  const posts = rows.map((r) => {
+    const media = safeJson(r.media_json)
+      .map((m) => ({ ...m, url: safeUrl(m.url) }))
+      .filter((m) => m.url);
+    const image = media.find((m) => m.type === 'image') || null;
+    return {
+      id: r.id,
+      title: r.title || '(zonder titel)',
+      summary: r.summary || '',
+      url: safeUrl(r.url),
+      published: r.published,
+      actorName: r.actor_name || 'Onbekend',
+      actorUrl: safeUrl(r.actor_url),
+      actorAvatar: safeUrl(r.actor_avatar),
+      image: image ? image.url : null,
+    };
+  });
+
+  renderPage(req, res, 'pages/circle-feed', { pageTitle: 'Cirkel', posts });
+});
+
+export default router;
Index: src/routes/federation.js
===================================================================
--- src/routes/federation.js	(revision 57929aaab80d03a3b7ca89b30a1687c618e5b5e8)
+++ src/routes/federation.js	(revision 57929aaab80d03a3b7ca89b30a1687c618e5b5e8)
@@ -0,0 +1,38 @@
+// routes/federation.js — publieke Cirkels-endpoints (v1, publicatie-kant).
+//
+//   GET /.klonkt/actor.json   — ActivityStreams-actor + Ed25519-pubkey
+//   GET /.klonkt/outbox.json  — publieke posts als AS Create-objecten,
+//                               getekend via de Klonkt-Signature-header
+//
+// Site-agnostisch en zonder auth — alleen lezen. Zie docs/cirkels-v1-spec.md.
+
+import express from 'express';
+import { buildActor, buildOutbox, signBody } from '../services/CircleFederation.js';
+import { getTenancy } from '../services/SettingsService.js';
+
+const router = express.Router();
+
+function baseUrl(req) {
+  const b = process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`;
+  return b.replace(/\/+$/, '');
+}
+
+router.get('/.klonkt/actor.json', (req, res) => {
+  // Cirkels = solo-naar-solo; hubs publiceren geen federatie-actor.
+  if (getTenancy() === 'hub') return res.status(404).type('text/plain').send('Niet beschikbaar in hub-modus');
+  const body = JSON.stringify(buildActor(baseUrl(req)), null, 2);
+  res.type('application/activity+json; charset=utf-8');
+  res.set('Cache-Control', 'public, max-age=300');
+  res.send(body);
+});
+
+router.get('/.klonkt/outbox.json', (req, res) => {
+  if (getTenancy() === 'hub') return res.status(404).type('text/plain').send('Niet beschikbaar in hub-modus');
+  const body = JSON.stringify(buildOutbox(baseUrl(req)), null, 2);
+  res.type('application/activity+json; charset=utf-8');
+  res.set('Cache-Control', 'public, max-age=300');
+  res.set('Klonkt-Signature', `ed25519=${signBody(body)}`);
+  res.send(body);
+});
+
+export default router;
