Index: src/middleware/ward-profile.js
===================================================================
--- src/middleware/ward-profile.js	(revision 746d98a771aa1e4a4241958d88582d516570cf4b)
+++ src/middleware/ward-profile.js	(revision 746d98a771aa1e4a4241958d88582d516570cf4b)
@@ -0,0 +1,108 @@
+/**
+ * De wardmodus: een besloten Klonkt waarvan alleen het profiel openbaar is
+ * (Robins ontwerp, 30-9).
+ *
+ * AAN ZOLANG DE SITE GUARDIANS HEEFT, en dat wordt niet opgeslagen maar
+ * afgeleid. listGuardians telt alleen GEACCEPTEERDE guardianships: de modus gaat
+ * aan zodra een koppeling rond is (niet bij een voorstel), en uit zodra de set
+ * leeg is -- en de set leegmaken is emancipatie (FEP-633c 3.4). Dat is precies
+ * waar het ontwerp de terugweg naar een gewone Klonkt legt. Een schakelaar zou
+ * een tweede waarheid naast de guardianship zijn, die kan vergeten worden of
+ * uit de pas kan lopen.
+ *
+ * WAT EEN BEZOEKER ZIET: de voorpagina wordt het profiel (naam, foto, bio,
+ * links), en verder niets. Elke andere pagina stuurt terug naar dat profiel;
+ * feeds, zoeken en alles wat geen pagina is geeft 404. STANDAARD DICHT: wat hier
+ * niet op de lijst staat komt er niet langs, zodat een nieuwe route niet vanzelf
+ * een kind openzet.
+ *
+ * WAT GEWOON DOORGAAT, en waarom:
+ *   - de federatie (ActivityPub, WebFinger, OAuth voor de Shaer-app) en /media,
+ *     /assets, /og: die hangen in server.js VOOR deze poort. Het kind post en
+ *     volgt via Shaer, en de volgpoort van de guardians regelt wie er meeleest;
+ *   - inloggen, het account, beheer en het guardianpaneel: die hebben hun eigen
+ *     bewaking, en de ward moet erbij kunnen;
+ *   - /audio/stream/: bewaakt elk bestand zelf, en een track die de ward op de
+ *     fediverse opende moet daar speelbaar blijven.
+ *
+ * WIE ALLES ZIET: de ward zelf (eigenaar), en wie de site mag beheren of
+ * bekijken (god, kijker, sitebeheer) -- dezelfde grens als elders.
+ */
+
+import * as Guardianship from '../services/guardianship/index.js';
+import PermissionsService from '../services/PermissionsService.js';
+import { parseProfileLinks } from '../services/PlatformIcons.js';
+import { renderPage } from './render.js';
+
+// Wat een bezoeker in de wardmodus WEL bereikt. Een map telt met alles eronder.
+const OPEN_DIRS = ['/auth', '/account', '/admin', '/guardian', '/audio/stream', '/lang',
+  // De federatie hangt in server.js al VOOR deze poort en komt hier dus nooit
+  // langs. Ze staan er toch, voor als iemand de volgorde ooit omgooit: dan zou
+  // een ward stil van de fediverse verdwijnen, en precies dat mag een
+  // opruimactie niet kunnen veroorzaken.
+  '/ap', '/.well-known', '/nodeinfo', '/oauth'];
+const OPEN_FILES = new Set(['/manifest.webmanifest', '/favicon.svg', '/favicon.ico', '/sw.js', '/robots.txt']);
+
+function isOpenPath(p) {
+  if (OPEN_FILES.has(p)) return true;
+  return OPEN_DIRS.some((d) => p === d || p.startsWith(d + '/'));
+}
+
+/** Heeft deze site guardians? Dan is het een ward, en staat de modus aan. */
+export function isWardSite(site) {
+  if (!site || !site.slug) return false;
+  try { return Guardianship.listGuardians(site.slug).length > 0; } catch { return false; }
+}
+
+/** Ziet deze gebruiker de hele site, ook in de wardmodus? */
+export function seesWholeSite(user, site) {
+  if (!user) return false;
+  if (user.role === 'god' || user.role === 'kijker') return true;
+  try { return !!PermissionsService.canAdminSite(user, site); } catch { return false; }
+}
+
+/** De fediverse-handle van de site: @slug@host. */
+function handleOf(req, site) {
+  let host = '';
+  try { host = new URL(process.env.PUBLIC_BASE_URL || '').host; } catch { /* terugval hieronder */ }
+  if (!host) host = req.get('host') || '';
+  return host ? `@${site.slug}@${host}` : `@${site.slug}`;
+}
+
+function renderWardProfile(req, res) {
+  const site = res.locals.site;
+  return renderPage(req, res, 'pages/ward-profile', {
+    pageTitle: site.profile_name || site.title || site.slug,
+    bodyClass: 'on-ward-profile',
+    wardHandle: handleOf(req, site),
+    wardLinks: parseProfileLinks(site.profile_links),
+  });
+}
+
+export function wardProfileGate(req, res, next) {
+  const site = res.locals.site;
+  res.locals.wardProfile = false;
+  if (!isWardSite(site)) return next();
+  if (seesWholeSite(req.session && req.session.user, site)) return next();
+
+  // Vanaf hier: een bezoeker van een ward-site. De views lezen deze vlag om
+  // niets aan te bieden wat toch dicht is (de zoekknop bijvoorbeeld).
+  res.locals.wardProfile = true;
+  const p = req.path;
+  if (isOpenPath(p)) return next();
+
+  const isRead = req.method === 'GET' || req.method === 'HEAD';
+  if (isRead && p === '/') return renderWardProfile(req, res);
+
+  const base = res.locals.siteUrlBase || '';
+  if (isRead && req.get('hx-request') === 'true') {
+    // Een htmx-navigatie zou het profiel als brokstuk in de pagina zetten.
+    // HX-Redirect laat de browser echt naar het profiel gaan.
+    res.set('HX-Redirect', base + '/');
+    return res.status(204).end();
+  }
+  if (isRead && req.accepts(['html', 'json']) === 'html') return res.redirect(302, base + '/');
+  return res.status(404).end();
+}
+
+export default { wardProfileGate, isWardSite, seesWholeSite };
