Index: src/routes/account.js
===================================================================
--- src/routes/account.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/routes/account.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -16,18 +16,16 @@
 import path from 'path';
 import fs from 'fs';
+import { fileURLToPath } from 'url';
 import bcrypt from 'bcryptjs';
 import multer from 'multer';
 import { v4 as uuid } from 'uuid';
 import db from '../config/database.js';
-import { getPrimarySite } from '../middleware/site.js';
 import { renderPage } from '../middleware/render.js';
-import OAuth from '../services/OAuthService.js';
-import { t } from '../services/i18n.js';
 import { requireAuth } from '../middleware/auth.js';
-import { toWebp } from '../services/ImageWebpService.js';
-import { SUPPORTED } from '../services/i18n.js';
-import { mediaDir } from '../config/paths.js';
 
-const AVATAR_DIR = mediaDir('AVATAR_PATH', 'avatars');
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const AVATAR_DIR = path.resolve(
+  process.env.AVATAR_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'avatars')
+);
 fs.mkdirSync(AVATAR_DIR, { recursive: true });
 
@@ -59,21 +57,12 @@
 router.get('/', requireAuth, (req, res) => {
   const account = db.prepare(`
-    SELECT id, username, email, role, bio, avatar_url, created_at, password_hash, google_sub, lang
+    SELECT id, username, email, role, bio, avatar_url, created_at
     FROM users WHERE id = ?
   `).get(req.session.user.id);
-  const hasPassword = !!(account && account.password_hash && account.password_hash !== '!google-oauth');
-  if (account) { delete account.password_hash; delete account.google_sub; } // don't leak to the view
 
-  const editableSite = ownedSite(req.session.user);
   renderPage(req, res, 'pages/account', {
     pageTitle: 'Account',
     bodyClass: 'on-special',
     account,
-    hasPassword,
-    editableSite,
-    // Display fallback: when you have no own account avatar, show your site's photo.
-    siteAvatar: editableSite ? editableSite.profile_photo : null,
-    // OAuth apps (C2S) this user has authorized, so they can revoke them here.
-    authorizations: OAuth.listAuthorizations(req.session.user.id),
     success: req.query.success || null,
     error: req.query.error || null,
@@ -81,95 +70,10 @@
 });
 
-// ==================== REVOKE AN OAUTH APP AUTHORIZATION ====================
-router.post('/oauth/revoke', requireAuth, (req, res) => {
-  const lang = req.session.lang || (req.session.user && req.session.user.lang) || 'nl';
-  const ok = OAuth.revokeAuthorization(req.session.user.id, req.body.token_hash);
-  const msg = ok ? t(lang, 'acct.oauth_revoked') : t(lang, 'acct.oauth_revoke_none');
-  res.redirect('/account?' + (ok ? 'success' : 'error') + '=' + encodeURIComponent(msg));
-});
-
-// ==================== PERSONAL INTERFACE LANGUAGE ====================
-// Saves the language choice on the account (persists across devices/sessions) and
-// also sets it in the session immediately so it takes effect right away.
-router.post('/lang', requireAuth, (req, res) => {
-  const code = SUPPORTED.includes(req.body.lang) ? req.body.lang : null;
-  if (code) {
-    db.prepare('UPDATE users SET lang = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(code, req.session.user.id);
-    req.session.user.lang = code;
-    req.session.lang = code;
-  }
-  res.redirect('/account?success=' + encodeURIComponent('Taal opgeslagen'));
-});
-
-// The site this user may edit from their account: their own site
-// (owner_id), or for a god the primary site. Null if nothing found.
-function ownedSite(user) {
-  if (!user) return null;
-  let site = db.prepare('SELECT id, title, tagline, slug, owner_id, profile_photo FROM sites WHERE owner_id = ? ORDER BY created_at LIMIT 1').get(user.id);
-  if (!site && user.role === 'god') {
-    site = getPrimarySite(); // primary/main site as fallback
-  }
-  return site || null;
-}
-
-// ==================== UPDATE SITE-NAAM (eigenaar) ====================
-router.post('/site', requireAuth, (req, res) => {
-  const site = ownedSite(req.session.user);
-  if (!site) return res.redirect('/account?error=' + encodeURIComponent('Geen site om te bewerken.'));
-  if (site.owner_id !== req.session.user.id && req.session.user.role !== 'god') {
-    return res.redirect('/account?error=' + encodeURIComponent('Geen rechten om deze site te bewerken.'));
-  }
-  const title = (req.body.site_title || '').toString().slice(0, 200).trim();
-  if (!title) return res.redirect('/account?error=' + encodeURIComponent('Site-naam mag niet leeg zijn.'));
-  const tagline = (req.body.site_tagline || '').toString().slice(0, 200).trim();
-  db.prepare('UPDATE sites SET title = ?, tagline = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
-    .run(title, tagline || null, site.id);
-  res.redirect('/account?success=' + encodeURIComponent('Site-naam bijgewerkt'));
-});
-
 // ==================== UPDATE BIO ====================
-const RESERVED_USERNAMES = new Set(['admin', 'account', 'auth', 'login', 'register', 'logout', 'user', 'users', 'api', 'fediverse', 'posts', 'media', 'audio', 'assets', 'cirkel', 'authorize_interaction']);
-
 router.post('/profile', requireAuth, (req, res) => {
   const bio = (req.body.bio || '').toString().slice(0, 500).trim();
-
-  // Username (login + display name; does NOT affect the fediverse handle, which
-  // is the site slug). Validate: format + reserved + unique (case-insensitive).
-  const username = (req.body.username || '').toString().trim();
-  if (username && username !== req.session.user.username) {
-    if (!/^[A-Za-z0-9_-]{2,30}$/.test(username)) {
-      return res.redirect('/account?error=' + encodeURIComponent('Gebruikersnaam: 2-30 tekens; letters, cijfers, _ en - .'));
-    }
-    if (RESERVED_USERNAMES.has(username.toLowerCase())) {
-      return res.redirect('/account?error=' + encodeURIComponent('Die gebruikersnaam is gereserveerd.'));
-    }
-    const uTaken = db.prepare('SELECT 1 FROM users WHERE LOWER(username) = LOWER(?) AND id != ?').get(username, req.session.user.id);
-    if (uTaken) {
-      return res.redirect('/account?error=' + encodeURIComponent('Die gebruikersnaam is al in gebruik.'));
-    }
-    db.prepare('UPDATE users SET username = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(username, req.session.user.id);
-    req.session.user.username = username;
-  }
-
-  // Email (optionally also changed). Validation: valid format + not already in use
-  // by another account. Email is the login/reset anchor, so it must be unique.
-  const email = (req.body.email || '').toString().trim();
-  if (email) {
-    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254) {
-      return res.redirect('/account?error=' + encodeURIComponent('Voer een geldig e-mailadres in.'));
-    }
-    const taken = db.prepare('SELECT 1 FROM users WHERE LOWER(email) = LOWER(?) AND id != ?')
-      .get(email, req.session.user.id);
-    if (taken) {
-      return res.redirect('/account?error=' + encodeURIComponent('Dit e-mailadres is al in gebruik.'));
-    }
-    db.prepare('UPDATE users SET email = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
-      .run(email, req.session.user.id);
-    req.session.user.email = email; // update session so the UI reflects the change
-  }
-
   db.prepare('UPDATE users SET bio = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
     .run(bio || null, req.session.user.id);
-  res.redirect('/account?success=' + encodeURIComponent('Profiel bijgewerkt'));
+  res.redirect('/account?success=' + encodeURIComponent('Profile updated'));
 });
 
@@ -184,20 +88,16 @@
   const { current, new_password, confirm } = req.body;
   if (!current || !new_password || !confirm) {
-    return res.redirect('/account?error=' + encodeURIComponent('Alle wachtwoordvelden zijn verplicht'));
+    return res.redirect('/account?error=' + encodeURIComponent('All password fields required'));
   }
   if (new_password.length < 8) {
-    return res.redirect('/account?error=' + encodeURIComponent('Nieuw wachtwoord moet minstens 8 tekens zijn'));
+    return res.redirect('/account?error=' + encodeURIComponent('New password must be at least 8 characters'));
   }
   if (new_password !== confirm) {
-    return res.redirect('/account?error=' + encodeURIComponent('Nieuwe wachtwoorden komen niet overeen'));
+    return res.redirect('/account?error=' + encodeURIComponent('New passwords do not match'));
   }
 
   const row = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.session.user.id);
-  // Google-only accounts (listeners) have no real password.
-  if (!row || !row.password_hash || row.password_hash === '!google-oauth') {
-    return res.redirect('/account?error=' + encodeURIComponent('Dit account heeft geen wachtwoord (Google-login)'));
-  }
-  if (!bcrypt.compareSync(current, row.password_hash)) {
-    return res.redirect('/account?error=' + encodeURIComponent('Huidig wachtwoord is onjuist'));
+  if (!row || !bcrypt.compareSync(current, row.password_hash)) {
+    return res.redirect('/account?error=' + encodeURIComponent('Current password is incorrect'));
   }
 
@@ -206,5 +106,5 @@
     .run(newHash, req.session.user.id);
 
-  res.redirect('/account?success=' + encodeURIComponent('Wachtwoord gewijzigd'));
+  res.redirect('/account?success=' + encodeURIComponent('Password changed'));
 });
 
@@ -219,5 +119,5 @@
     }
 
-    const url = `/media/avatars/${toWebp(req.file)}`;
+    const url = `/media/avatars/${req.file.filename}`;
 
     // Remove the old avatar file (if it lives in our avatar dir)
Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,1496 +1,0 @@
-/**
- * ActivityPub — public endpoints (Phase 1: discover + fetch).
- *
- *   GET /.well-known/webfinger?resource=acct:<slug>@<host>
- *   GET /ap/users/:slug            actor (content-negotiated: AP-JSON vs redirect to HTML profile)
- *   GET /ap/users/:slug/outbox     OrderedCollection of Create(Note)
- *   GET /ap/users/:slug/followers  count-only OrderedCollection
- *   GET /ap/users/:slug/featured   pinned posts (Mastodon "Featured" tab)
- *   GET /ap/notes/:id              a single Note
- *   POST /ap/users/:slug/inbox, /ap/inbox  → 202 (Follow/Accept + signature verify: next step)
- *
- * Mounted before resolveSite; resolves the site by slug itself.
- */
-import express from 'express';
-import { readFileSync } from 'fs';
-import db, { isoSql } from '../config/database.js';
-import AP from '../services/ActivityPubService.js';
-import { apReadLimiter, apInboxLimiter } from '../middleware/rate-limit.js';
-import { apEnabled } from '../services/SettingsService.js';
-import OAuth from '../services/OAuthService.js';
-import * as Guardianship from '../services/guardianship/index.js';
-import * as Migration from '../services/MigrationService.js';
-import { getPrimarySite } from '../middleware/site.js';
-import multer from 'multer';
-import path from 'path';
-import fs from 'fs';
-import { randomUUID } from 'crypto';
-import { mediaDir } from '../config/paths.js';
-
-const router = express.Router();
-
-/**
- * Welke pagina vraagt de lezer? (shaer-sk4)
- *
- * Hier stond `!!req.query.page` -- of de parameter er STAAT, niet welke. Daardoor
- * gaf ?page=2 en ?page=99 allemaal pagina 1, en noemde het antwoord zichzelf ook
- * nog pagina 1. Onleesbaar getal of geen parameter: dan de wortel.
- */
-function paginaNr(req) {
-  if (req.query.page === undefined) return false;
-  const n = Math.floor(Number(req.query.page));
-  return Number.isFinite(n) && n > 0 ? n : 1;
-}
-// The whole fediverse layer can be turned off (solo "no federation" mode):
-// then /ap/*, WebFinger and NodeInfo are simply gone — the site is undiscoverable
-// and unfederatable. CRITICAL: this router is mounted at root (app.use(apRoutes)), so a
-// blanket res.status(404) here ran for EVERY request and 404'd the whole site when AP was
-// off. Use next('router') to SKIP this router entirely and let the normal routes handle it
-// (the /ap/* paths then fall through to the app's normal 404, which is correct).
-router.use((req, res, next) => { if (!apEnabled()) return next('router'); next(); });
-// Generous per-IP baseline over the AP-READ paths. The inbox POST gets an
-// additional, tighter cap inline (it triggers outbound fetches).
-//
-// PADGEBONDEN, niet router.use kaal (Barts 429-jacht, 9-8): deze router is op
-// de ROOT gemonteerd, dus een kale use() draait voor ELKE request van de hele
-// site -- pagina's, media, avatars, de PWA. De guardian-PWA met honderd
-// ward-avatars leegde zo in seconden een emmer die "voor /ap-reads" heette,
-// en hield hem leeg: vandaar een Too many requests die niet overging. De
-// kijkbuis die dit vond: een lege /ap-teller naast remaining: 0.
-router.use(['/ap', '/.well-known', '/nodeinfo'], apReadLimiter);
-let _ver = '1.0.0';
-try { _ver = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url))).version || _ver; } catch { /* keep default */ }
-
-const baseUrl = (req) => (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
-const hostOf = (req) => { try { return new URL(baseUrl(req)).host; } catch { return req.get('host'); } };
-const publicSite = (slug) => db.prepare('SELECT * FROM sites WHERE slug = ? AND (is_public IS NULL OR is_public = 1)').get(slug);
-// The primary site, via the one source of truth in middleware/site.js — which
-// falls back to the oldest site when nothing carries the is_primary flag. This
-// route used to keep its own is_primary-only copy, so a fresh instance whose
-// site was never flagged served its HTML at / (that resolver falls back) while
-// WebFinger and the actor route insisted it had no primary at all.
-const primarySlug = () => { const s = getPrimarySite(); return s && s.slug; };
-// A hostname as a human types it and as DNS stores it are the same host:
-// `🩵.is.wildenvrij.nl` IS `xn--zz9h.is.wildenvrij.nl`. WHATWG URL does the IDNA,
-// so compare the ASCII form and never the bytes the client happened to send.
-const asciiHost = (h) => {
-  try { return new URL(`https://${h}`).host.toLowerCase(); } catch { return String(h).trim().toLowerCase(); }
-};
-
-// ── host-meta ─────────────────────────────────────────────────────
-// De klassieke eerste stap van WebFinger (RFC 6415): een client die het
-// webfinger-pad niet wil raden, vraagt hier de sjabloon op. Mastodon serveert
-// dit ook, en een client die ermee begint kreeg bij ons een 404 en gaf het dan
-// op -- terwijl de webfinger eronder gewoon werkte.
-//
-// Twee vormen, want beide worden in het wild gevraagd: XRD (het origineel) en
-// JRD (de JSON-variant, RFC 6415 §3).
-const lrddSjabloon = (req) => `${baseUrl(req)}/.well-known/webfinger?resource={uri}`;
-
-router.get('/.well-known/host-meta', (req, res) => {
-  res.type('application/xrd+xml; charset=utf-8');
-  res.set('Cache-Control', 'public, max-age=86400');
-  res.send(`<?xml version="1.0" encoding="UTF-8"?>
-<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
-  <Link rel="lrdd" template="${lrddSjabloon(req)}"/>
-</XRD>`);
-});
-
-router.get('/.well-known/host-meta.json', (req, res) => {
-  res.type('application/jrd+json; charset=utf-8');
-  res.set('Cache-Control', 'public, max-age=86400');
-  res.send(JSON.stringify({ links: [{ rel: 'lrdd', template: lrddSjabloon(req) }] }));
-});
-
-// ── WebFinger ─────────────────────────────────────────────────────
-/**
- * De `resource` uitpakken tot de gebruiker die bedoeld wordt.
- *
- * RFC 7033 schrijft een URI voor, en `acct:` is de nette vorm -- maar in het
- * wild komen er vier spellingen langs, en drie daarvan wezen we af met een 400
- * terwijl we prima wisten wie er bedoeld werd:
- *
- *   acct:naam@host      de nette vorm (Mastodon stuurt altijd deze)
- *   naam@host           zonder schema
- *   @naam@host          met het apenstaartje dat mensen intypen
- *
- * Coulant zijn kost hier niets: het antwoord noemt altijd de canonieke
- * `acct:`-vorm terug, dus een slordige vraag levert geen slordig antwoord.
- *
- * De ACTOR-URI als resource (die Mastodon ook accepteert) hoort hier NIET bij,
- * bewust: test/webfinger-bare-host.test.js legt vast dat die een 400 geeft.
- * Dat is een uitgesproken keuze van eerder en geen vergetelheid, dus die draai
- * ik niet om als bijvangst van een coulance-fix.
- */
-function webfingerGebruiker(resource) {
-  const r = String(resource || '').trim();
-  if (!r) return null;
-  const acct = r.match(/^(?:acct:)?@?([^@/]+)@(.+)$/i);
-  return acct ? acct[1] : null;
-}
-
-/**
- * Is dit een vraag naar de WORTEL van deze server?
- *
- * FEP-61cf laat de home instance webfingeren op "the root URL of the destination
- * site" om ons token-endpoint te vinden. Dat is een URL en geen `acct:`, dus hij
- * strandde hierboven op de 400 -- terwijl een ACTOR-URI met een pad wel degelijk
- * een 400 hoort te blijven (dat legt test/webfinger-bare-host.test.js vast, en
- * dat is een uitgesproken keuze van eerder). Vandaar: alleen origin + '/' telt,
- * alles met een pad niet.
- */
-function isEigenWortel(resource, req) {
-  const r = String(resource || '').trim();
-  if (!/^https?:\/\//i.test(r)) return false;
-  try {
-    const u = new URL(r);
-    if (u.pathname && u.pathname !== '/') return false;
-    if (u.search || u.hash) return false;
-    return asciiHost(u.host) === asciiHost(hostOf(req));
-  } catch { return false; }
-}
-
-router.get('/.well-known/webfinger', (req, res) => {
-  if (isEigenWortel(req.query.resource, req)) {
-    res.type('application/jrd+json; charset=utf-8');
-    res.set('Cache-Control', 'public, max-age=300');
-    return res.send(JSON.stringify({
-      subject: baseUrl(req) + '/',
-      links: [
-        // Waar een home instance ondertekend een token mag ophalen (FEP-61cf
-        // stap 2/3). Host-niveau en niet per site: het token zegt WIE er binnen
-        // is, niet waar hij binnen mag -- dat besluit valt bij de poort.
-        { rel: 'http://purl.org/openwebauth/v1', href: baseUrl(req) + '/owa/token' },
-      ],
-    }));
-  }
-  const user = webfingerGebruiker(req.query.resource);
-  if (!user) return res.status(400).type('text/plain').send('bad resource');
-  let site = publicSite(user);
-  // `acct:<host>@<host>` asks for this server's primary actor — the convention
-  // Shaer's Handle relies on so a Ward is reachable without knowing anyone's
-  // slug. Typing `🩵.is.wildenvrij.nl`, pasting `https://🩵.is.wildenvrij.nl`
-  // (which the client's URL parser silently punycodes) and sending the xn--
-  // form by hand are three spellings of one address; all arrive here with the
-  // host sitting in the user position, and all must find the same actor.
-  if (!site && asciiHost(user) === asciiHost(hostOf(req))) {
-    const slug = primarySlug();
-    if (slug) site = publicSite(slug);
-  }
-  if (!site) return res.status(404).end();
-  res.type('application/jrd+json; charset=utf-8');
-  res.set('Cache-Control', 'public, max-age=300');
-  const actorUri = AP.actorId(baseUrl(req), site.slug);
-  const profileUrl = baseUrl(req) + (site.slug === primarySlug() ? '/' : `/user/${encodeURIComponent(site.slug)}`);
-  res.send(JSON.stringify({
-    subject: `acct:${site.slug}@${hostOf(req)}`,
-    aliases: [actorUri, profileUrl],
-    links: [
-      { rel: 'self', type: 'application/activity+json', href: actorUri },
-      { rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: profileUrl },
-      // FEP-61cf: hier stuurt een doelsite deze gebruiker heen om zich te
-      // bewijzen. Zonder deze regel valt zo'n site terug op /magic geraden --
-      // wat toevallig klopt, maar raden is geen afspraak.
-      { rel: 'http://purl.org/openwebauth/v1#redirect', href: baseUrl(req) + '/magic' },
-    ],
-  }));
-});
-
-// ── Actor ─────────────────────────────────────────────────────────
-router.get('/ap/users/:slug', (req, res) => {
-  const site = publicSite(req.params.slug);
-  if (!site) return res.status(404).end();
-  if (!AP.apWants(req)) {
-    // A browser hit the AP actor URL → send them to the human profile.
-    const human = site.slug === primarySlug() ? '/' : `/user/${encodeURIComponent(site.slug)}`;
-    return res.redirect(302, baseUrl(req) + human);
-  }
-  site.primary_slug = primarySlug();
-  AP.sendAP(res, AP.buildActor(baseUrl(req), site));
-});
-
-// ── Outbox ────────────────────────────────────────────────────────
-router.get('/ap/users/:slug/outbox', async (req, res) => {
-  const site = publicSite(req.params.slug);
-  if (!site) return res.status(404).end();
-  // Authorized fetch (30-7): who is asking decides what they see.
-  //  - the owner's own app (bearer) and a verified accepted follower or
-  //    guardian get the friends-only history too, so a NEW friend's backfill
-  //    brings the past along (Robins besluit: vrienden krijgen de
-  //    geschiedenis mee);
-  //  - a verified caller this instance BLOCKS gets an EMPTY collection, not
-  //    even the public set: a block is a closed door, and a signed fetch is
-  //    the caller knocking with their name on it;
-  //  - everyone else gets the public collection, exactly as before.
-  const bearer = OAuth.verifyBearer(req.headers.authorization);
-  let verifiedActor = null;
-  if (!bearer && req.headers['signature']) {
-    const verified = await AP.verifyRequest(req).catch(() => null);
-    verifiedActor = verified && verified.id;
-  }
-  const audience = AP.outboxAudience(req.params.slug, {
-    bearerSlug: bearer ? bearer.site.slug : null,
-    verifiedActor,
-  });
-  if (audience === 'blocked') {
-    return AP.sendAP(res, AP.buildOutbox(baseUrl(req), site, [], [], { page: paginaNr(req) }), 'private, no-store');
-  }
-  // ECHT DOORBLADEREN (shaer-sk4). Hier stonden twintig posts uit SQL met een
-  // tweede kap van twintig eroverheen: alles daarvoor was niet op een volgende
-  // pagina maar helemaal onbereikbaar. outboxSlice pagineert over de UNION van
-  // posts en tracks, want die vlechten op datum en zijn met twee losse queries
-  // niet te offsetten.
-  //
-  // De tracks gaan mee voor iedereen die de deur door mag; de blocked-tak
-  // hierboven levert bewust een outbox ZONDER posts en zonder tracks.
-  const nr = paginaNr(req);
-  const { posts, tracks, totaal } = AP.outboxSlice(site.id, {
-    fanOnly: audience === 'friend',
-    offset: (Math.max(1, nr || 1) - 1) * AP.PAGINA_GROOTTE,
-    limit: AP.PAGINA_GROOTTE,
-  });
-  // FEP-1580: de instantie waar dit account naartoe verhuisd is krijgt de
-  // RAUWE inhoud, met [[track:]] en [[playlist:]] er nog in. Zij is een
-  // Klonkt en rendert die zelf tot een speler. De gebakken variant komt
-  // daar aan als tekstlink, en is bovendien onherstelbaar afgeknot: het
-  // bakken plakt hooguit vier titels aan.
-  const rauweInhoud = verifiedActor ? AP.isMoveTarget(site.slug, verifiedActor) : false;
-  const ob = AP.buildOutbox(baseUrl(req), site, posts, tracks, { page: nr, totalItems: totaal, alGesneden: true, rauweInhoud });
-  if (audience === 'friend') {
-    // The owner's app builds its feed from this leg, and every note here is
-    // by the site itself, so give it a byline too (avatar + name): de
-    // ingesloten actor in attributedTo, net als de tijdlijn.
-    const me = AP.selfAuthor(baseUrl(req), site);
-    // De kaart op je eigen post (shaer-k3f): dezelfde quote/preview die de
-    // tijdlijn voor andermans posts draagt, uit de snapshots die
-    // deliverCreate bij het publiceren opsloeg. Op note-id gekoppeld, want
-    // buildOutbox sorteert en mengt tracks erdoorheen. De embed alleen voor de
-    // BEARER en langs zijn eigen poort: een remote vriend krijgt hem niet
-    // (diens server resolvet en gate zelf bij ontvangst), en een ward zonder
-    // open embeds-poort krijgt hem hier net zo min als in de tijdlijn.
-    const byNote = new Map(posts.map((p) => [AP.noteId(baseUrl(req), p.id), p]));
-    const bearerEmbeds = bearer ? (() => {
-      const isWard = (() => { try { return Guardianship.listGuardians(bearer.site.slug).length > 0; } catch { return false; } })();
-      return Guardianship.externalEmbedsAllowed(bearer.site.external_embeds, isWard)
-        ? { playback: Guardianship.externalPlaybackAllowed(bearer.site.external_playback, isWard) } : null;
-    })() : null;
-    for (const it of ob.orderedItems) {
-      if (it && it.object && typeof it.object === 'object') {
-        it.object.attributedTo = AP.actorObject(
-          (typeof it.object.attributedTo === 'string' ? it.object.attributedTo : undefined) || AP.actorId(baseUrl(req), site.slug),
-          me,
-        );
-        const row = byNote.get(it.object.id);
-        if (row) {
-          it.object.quote = AP.quoteObject(row.quote_json);
-          if (bearerEmbeds) {
-            it.object.preview = AP.previewObject(row.embed_json, { playback: bearerEmbeds.playback });
-          }
-        }
-      }
-    }
-  }
-  AP.sendAP(res, ob, audience === 'friend' ? 'private, no-store' : undefined);
-});
-
-// ── Follow-QR (Robins verzoek, 31-7) ──────────────────────────────
-// The QR carries an HTTPS url, not the share: scheme: camera apps (Google
-// Lens voorop) treat unknown schemes as plain text and only offer to OPEN
-// https links (Robins melding, 31-7). The url lands on the interstitial
-// below, whose one big button fires the share: scheme — from a browser the
-// custom scheme DOES work (BROWSABLE intent-filter; Safari prompts).
-// Public on purpose: it encodes only the public handle, and the app's plain
-// image loaders carry no bearer.
-router.get('/ap/users/:slug/follow-qr.png', async (req, res) => {
-  const site = db.prepare('SELECT slug FROM sites WHERE slug = ?').get(req.params.slug);
-  if (!site) return res.status(404).end();
-  try {
-    const { default: QRCode } = await import('qrcode');
-    const png = await QRCode.toBuffer(`${baseUrl(req)}/ap/users/${encodeURIComponent(site.slug)}/follow`, { width: 600, margin: 1 });
-    res.set('Content-Type', 'image/png');
-    res.set('Cache-Control', 'public, max-age=86400');
-    res.send(png);
-  } catch (e) {
-    console.warn('[AP] follow-qr failed:', e && e.message);
-    res.status(500).end();
-  }
-});
-
-// The interstitial the QR opens: one big button into Shaer, and the handle
-// in plain sight for whoever has no Shaer (yet).
-router.get('/ap/users/:slug/follow', (req, res) => {
-  const site = db.prepare('SELECT slug, title FROM sites WHERE slug = ?').get(req.params.slug);
-  if (!site) return res.status(404).end();
-  const host = new URL(baseUrl(req)).host;
-  const esc = (t) => String(t).replace(/[<>&"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[c]));
-  const handle = `@${site.slug}@${host}`;
-  const name = esc(site.title || site.slug);
-  res.set('Cache-Control', 'public, max-age=3600');
-  res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
-<meta name="viewport" content="width=device-width, initial-scale=1">
-<title>Follow ${name}</title>
-<style>
-  body { font-family: system-ui, sans-serif; margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
-         background: linear-gradient(160deg, #5A32E6, #2a1a5e); color: #fff; text-align: center; }
-  main { padding: 32px; max-width: 420px; }
-  h1 { font-size: 1.5rem; margin: 0 0 .4rem; }
-  .handle { opacity: .85; font-family: ui-monospace, monospace; word-break: break-all; }
-  a.go { display: block; margin: 28px auto 14px; padding: 16px 28px; border-radius: 999px; background: #fff; color: #2a1a5e;
-         font-weight: 700; font-size: 1.15rem; text-decoration: none; }
-  p.small { font-size: .85rem; opacity: .75; line-height: 1.5; }
-</style></head><body><main>
-  <h1>Follow ${name}</h1>
-  <div class="handle">${esc(handle)}</div>
-  <a class="go" href="share:social/follow/AP/${esc(handle)}">Open in Shaer</a>
-  <p class="small">No Shaer? Any fediverse app can follow ${esc(handle)}.</p>
-</main></body></html>`);
-});
-
-/** De byline-gegevens uit een tijdlijnrij, langs de emoji-poort. */
-function authorInfoFrom(r, prefix, gates) {
-  const info = {
-    name: r[`${prefix}name`] || undefined, handle: r[`${prefix}handle`] || undefined,
-    icon: r[`${prefix}icon`] || undefined, url: r[`${prefix}url`] || undefined,
-    emojis: (() => { try { return r[`${prefix}emoji_json`] ? JSON.parse(r[`${prefix}emoji_json`]) : undefined; } catch { return undefined; } })(),
-  };
-  return (info.name || info.handle || info.icon) ? gates.gateAuthor(info) : undefined;
-}
-
-// ── Een tijdlijnpost als AS2-item: EEN beschrijving van de kaartvorm ──
-//
-// Zelfde reden als messageItem hieronder: de volledige lezing en de
-// verschil-lezing bouwen dezelfde kaart, en twee beschrijvingen lopen uit de
-// pas zonder dat iemand het merkt.
-function timelineItem(t, { p, reactions }) {
-  const authorInfo = (r, prefix) => authorInfoFrom(r, prefix, p);
-  const {
-    embedsAllowed, playbackAllowed, imagesAllowed, musicAllowed, quotesAllowed, emojiAllowed,
-  } = p;
-  const reacties = reactions || new Map();
-    const auteur = authorInfo(t, 'author_');
-    const booster = authorInfo(t, 'reblog_');
-    const boosterUri = t.reblog_url || t.reblog_handle || undefined;
-    return {
-    id: `${t.id}#create`,
-    // EEN BOOST IS EEN ANNOUNCE (shaer-nmw): een Create met een
-    // zijkanaal-property was onze uitvinding; de wrapper is de standaard, en
-    // elke AP-client leest hem al.
-    type: booster ? 'Announce' : 'Create',
-    actor: booster ? (AP.actorObject(boosterUri || t.author_uri, booster)) : t.author_uri,
-    published: t.published || t.created_at || undefined,
-    object: {
-      id: t.id,
-      type: 'Note',
-      // AS2 staat een INGESLOTEN actor toe; dan heeft elke client de byline,
-      // niet alleen de onze (shaer-nmw).
-      attributedTo: AP.actorObject(t.author_uri, auteur),
-      content: t.content,
-      url: t.url || undefined,
-      published: t.published || t.created_at || undefined,
-      sensitive: !!t.nsfw,
-      summary: t.cw || undefined,
-      // Friends' media travels along (media_json → AS2 attachment), so the
-      // client renders their images/audio like own outbox posts.
-      attachment: AP.gateAttachments(AP.timelineAttachments(t.media_json), { images: imagesAllowed, audio: musicAllowed }),
-      // The note's preserved tags, so the client can render them: FEP-9098
-      // Emoji tags (:shortcode: → image) and FEP-e232 Link tags (quotes /
-      // inline object references). Combined into one `tag` array; omitted
-      // when the note has neither.
-      tag: (() => {
-        const tags = [...(emojiAllowed ? (AP.timelineEmojis(t.emoji_json) || []) : []), ...(AP.timelineObjectLinks(t.link_json) || [])];
-        return tags.length ? tags : undefined;
-      })(),
-      // Whether THIS account already liked/boosted the note, so the app's
-      // detail-view buttons show the current state (and can toggle/undo).
-      'shaer:liked': !!(reacties.get(t.id) || {}).liked,
-      'shaer:boosted': !!(reacties.get(t.id) || {}).boosted,
-      // FEP-044f: de geciteerde post als object, zodat de client een kaart
-      // rendert in plaats van een kale link. AS2 preview is diezelfde kaart
-      // voor een EXTERNE link: thumbnail, nooit de iframe van de aanbieder.
-      // Allebei weg zodra hun poort dicht staat; de speler in preview hangt
-      // aan de playback-poort.
-      quote: quotesAllowed ? AP.quoteObject(t.quote_json) : undefined,
-      preview: embedsAllowed ? AP.previewObject(t.embed_json, { playback: playbackAllowed }) : undefined,
-    },
-  };
-}
-
-/** Een inkomend antwoord op je eigen post als AS2-item. */
-function replyItem(m, { base, me, myHandle, p }) {
-  return {
-    id: `${m.object_uri}#create`,
-    type: 'Create',
-    actor: m.actor_uri,
-    published: AP.isoStamp(m.published || m.created_at),
-    object: {
-      id: m.object_uri,
-      type: 'Note',
-      attributedTo: AP.actorObject(m.actor_uri, (m.actor_name || m.actor_handle || m.actor_icon) ? p.gateAuthor({
-        name: m.actor_name || undefined, handle: m.actor_handle || undefined,
-        icon: m.actor_icon || undefined, url: m.actor_url || undefined,
-        emojis: (() => { try { return m.actor_emoji_json ? JSON.parse(m.actor_emoji_json) : undefined; } catch { return undefined; } })(),
-      }) : undefined),
-      content: AP.stripLeadingMentions(m.content),
-      inReplyTo: m.parent_uri || `${base}/ap/notes/${m.post_id}`,
-      published: AP.isoStamp(m.published || m.created_at),
-      to: [me],
-      tag: [{ type: 'Mention', href: me, name: myHandle }, ...(AP.timelineEmojis(m.emoji_json) || [])],
-      attachment: AP.timelineAttachments(m.media_json),
-      quote: p.quotesAllowed ? AP.quoteObject(m.quote_json) : undefined,
-      preview: p.embedsAllowed ? AP.previewObject(m.embed_json, { playback: p.playbackAllowed }) : undefined,
-    },
-  };
-}
-
-/** Wat deze lezer mag (FEP-633c 5.6), op EEN plek.
- *
- *  De verschil-lezing draagt ze net zo goed: een antwoord zonder rechten zou
- *  de client naar zijn standaard laten terugvallen, en die standaard is
- *  'alles mag'. Dan zet een gesloten poort zichzelf stil open. Dezelfde reden
- *  waarom een 304 de caps met rust laat.
- */
-function capabilitiesOf(p, gate) {
-  return {
-      'shaer:externalEmbeds': p.embedsAllowed,
-      'shaer:externalPlayback': p.playbackAllowed,
-      // Leaving the app is the same decision as playing inside it: with the
-      // gate shut a link is shown but not followed, so the door is closed too
-      // and not just the picture over it.
-      'shaer:externalLinks': p.playbackAllowed,
-      // De rest van de familie (8-8): de app hoort VOORAF te weten wat hij mag
-      // aanbieden in plaats van het bij de eerste weigering te ontdekken. De
-      // (+) kaart leest shaer:compose al (Barts gate); de rest is er voor de
-      // schermen die nog komen. Serveren wat waar is kost hier niets.
-      'shaer:compose': p.composeAllowed,
-      'shaer:replies': p.repliesAllowed,
-      'shaer:messages': p.messagesAllowed,
-      'shaer:images': p.imagesAllowed,
-      'shaer:music': p.musicAllowed,
-      'shaer:quoteCards': p.quotesAllowed,
-      'shaer:customEmoji': p.emojiAllowed,
-      'shaer:externalThreads': p.threadsAllowed,
-      'shaer:following': p.followingAllowed,
-      // Stond in de catalogus mét kolom, en ontbrak hier: de guardian zag de
-      // poort in zijn paneel en de app van het kind heeft er nooit van gehoord.
-      // Gevonden door de pariteitstest, niet door iemand die het toevallig zag.
-      'shaer:accountMove': gate('gate_account_move'),
-    };
-}
-
-// ── De poorten van een lezer, op EEN plek (FEP-633c) ─────────────
-//
-// De inbox-lezing rekende ze inline uit. Nu er meer lezingen zijn die
-// dezelfde poorten moeten eerbiedigen (de gesprekken, de geschiedenis), zou
-// dat evenveel kopieen worden -- en een poort die op een van die plekken
-// vergeten wordt, levert stil iets uit dat dicht hoorde te staan.
-function gatesFor(site) {
-  const isWard = (() => { try { return Guardianship.listGuardians(site.slug).length > 0; } catch { return false; } })();
-  const embeds = Guardianship.externalEmbedsAllowed(site.external_embeds, isWard);
-  const gate = (col) => Guardianship.wardGateAllowed(site[col], isWard);
-  const emoji = gate('gate_custom_emoji');
-  return {
-    isWard,
-    embedsAllowed: embeds,
-    playbackAllowed: embeds && Guardianship.externalPlaybackAllowed(site.external_playback, isWard),
-    imagesAllowed: gate('gate_images'),
-    musicAllowed: gate('gate_music'),
-    quotesAllowed: gate('gate_quote_cards'),
-    emojiAllowed: emoji,
-    messagesAllowed: gate('gate_messages'),
-    composeAllowed: gate('gate_compose'),
-    repliesAllowed: gate('gate_replies'),
-    threadsAllowed: gate('external_threads'),
-    followingAllowed: gate('gate_following'),
-    // Emoji dicht raakt ook de bylines: de plaatjes in een naam komen net zo
-    // goed van een vreemde server. De naam zelf blijft, met :shortcode: als tekst.
-    gateAuthor: (a) => (a && !emoji ? { ...a, emojis: undefined } : a),
-  };
-}
-
-/**
- * De naam waaronder deze lezer zichzelf herkent in een Mention.
- *
- * Via deriveHandle op de actor-URI, niet uit de slug hier opgebouwd. Dit stond
- * er als `@${slug}@${host}` met `@${slug}` als terugval, en die terugval is een
- * HALVE naam: zonder host zegt @dev niets op een oppervlak waar iedereen @dev
- * kan heten. Hij ging alleen af bij een onparseerbare PUBLIC_BASE_URL -- maar
- * dan klopt elke URI die we bouwen al niet, en is de kale actor-URI (wat
- * deriveHandle dan teruggeeft) eerlijker dan een naam die compleet lijkt.
- */
-function ownHandle(base, slug) {
-  return AP.deriveHandle(AP.actorId(base, slug));
-}
-
-// ── Een bericht als AS2-item: EEN beschrijving van de kaartvorm ──
-//
-// Gebruikt door de inbox-lezing en door de gesprekslezingen. Twee keer
-// opschrijven is twee vormen die uit de pas kunnen lopen, en dat merk je pas
-// als een kaart ergens anders rendert dan waar je keek.
-function messageItem(m, { base, me, myHandle, p }) {
-  return {
-    id: `${m.object_uri}#create`,
-    type: 'Create',
-    actor: m.actor_uri,
-    published: AP.isoStamp(m.published || m.created_at),
-    object: {
-      id: m.object_uri,
-      type: 'Note',
-      attributedTo: AP.actorObject(m.actor_uri, (m.actor_name || m.actor_handle || m.actor_icon) ? p.gateAuthor({
-        name: m.actor_name || undefined, handle: m.actor_handle || undefined,
-        icon: m.actor_icon || undefined, url: m.actor_url || undefined,
-        emojis: (() => { try { return m.actor_emoji_json ? JSON.parse(m.actor_emoji_json) : undefined; } catch { return undefined; } })(),
-      }) : undefined),
-      content: AP.stripLeadingMentions(m.content),
-      url: m.note_url || undefined,
-      // Waar dit een antwoord op is (Robins melding, 26-8). Zonder dit veld
-      // kwam elk antwoord binnen als het begin van een gesprek: de client kan
-      // een keten alleen teruglopen langs inReplyTo. De andere twee legs
-      // (replyItem uit ap_interactions, sentItem via buildNote) droegen hem
-      // al -- deze was de enige die hem niet eens opsloeg.
-      inReplyTo: m.in_reply_to || undefined,
-      published: AP.isoStamp(m.published || m.created_at),
-      // Addressed to us and to nobody we know of: the other recipients of a
-      // note to several people are not ours to see, so we serve what we know.
-      to: [me],
-      // The Mention is how the client recognises itself as the addressee and
-      // groups the note into a conversation. No FEP-e232 link tags here: a
-      // mention row keeps the resolved quote, not the raw tags.
-      tag: [{ type: 'Mention', href: me, name: myHandle }, ...(p.emojiAllowed ? (AP.timelineEmojis(m.emoji_json) || []) : [])],
-      attachment: AP.gateAttachments(AP.timelineAttachments(m.media_json), { images: p.imagesAllowed, audio: p.musicAllowed }),
-      // FEP-633c: what kind of message this is. The wave is a gentle nudge from
-      // a guardian; the help request is the buoy. Both render differently.
-      'shaer:wave': m.wave ? true : undefined,
-      'shaer:helpRequest': m.help_request ? true : undefined,
-      quote: p.quotesAllowed ? AP.quoteObject(m.quote_json) : undefined,
-      preview: p.embedsAllowed ? AP.previewObject(m.embed_json, { playback: p.playbackAllowed }) : undefined,
-    },
-  };
-}
-
-/** Een eigen verzonden note als AS2-item, zelfde vorm als de inbox-leg. */
-function sentItem(n, { me, mine }) {
-  return {
-    id: `${n.id}#create`,
-    type: 'Create',
-    actor: me,
-    published: n.published,
-    // The leading mention anchor is addressing, not prose (the DM leg strips
-    // it the same way); the Mention tags built from the full content stay.
-    object: {
-      ...n, content: AP.stripLeadingMentions(n.content),
-      attributedTo: AP.actorObject(typeof n.attributedTo === 'string' ? n.attributedTo : me, mine),
-    },
-  };
-}
-
-// ── Gesprekken: eerst wie, dan pas wat (shaer-frontend-yso) ──────
-//
-// Twee lezingen naast de bestaande inbox-lezing, niet in de plaats ervan: de
-// apps in het veld lezen die nog. /conversations geeft EEN rij per tegenpartij
-// -- compleet van vorm, dus de avatarhemel kan niemand kwijtraken doordat een
-// ander druk was -- en /messages geeft een gesprek met een cursor, zodat een
-// 'load more' eerlijk kan verschijnen in plaats van dat de geschiedenis stil
-// ophoudt.
-//
-// Beide lopen langs dezelfde poorten als de inbox-lezing (gatesFor) en
-// dezelfde kaartvorm (messageItem/sentItem). Messages dicht sluit ook
-// hier vreemden en vrienden, maar nooit het guardian-kanaal en nooit de boei.
-function conversationItems(req, auth, refs) {
-  const base = baseUrl(req);
-  const P = gatesFor(auth.site);
-  const me = AP.actorId(base, auth.site.slug);
-  const ctx = { base, me, myHandle: ownHandle(base, auth.site.slug), p: P };
-  const mine = AP.selfAuthor(base, auth.site);
-  const guardianUris = (() => { try { return new Set(Guardianship.listGuardians(auth.site.slug).map((g) => g.other_uri)); } catch { return new Set(); } })();
-
-  const incoming = new Map(AP.messageRowsByUri(auth.site.slug, refs.filter((r) => r.direction === 'in').map((r) => r.ref))
-    .map((m) => [m.object_uri, m]));
-  // PAREN, geen losse lijst: een kop levert niet altijd een item op (dichte
-  // poort, ontbrekende rij), en dan zou de aanroeper op index koppelen en de
-  // telling aan het verkeerde gesprek hangen. Stil, en pas te zien als iemand
-  // een badge op de verkeerde naam ziet staan.
-  const pairs = [];
-  for (const r of refs) {
-    if (r.direction === 'in') {
-      const m = incoming.get(r.ref);
-      if (!m) continue;
-      if (!(P.messagesAllowed || m.help_request || guardianUris.has(m.actor_uri))) continue;
-      pairs.push({ head: r, item: messageItem(m, ctx) });
-    } else {
-      const n = AP.getOutboxNote(base, r.ref);
-      // Je eigen woorden blijven van jou: een dichte messages-poort verbergt
-      // niet wat je zelf gezegd hebt.
-      if (n) pairs.push({ head: r, item: sentItem(n, { me, mine }) });
-    }
-  }
-  return pairs;
-}
-
-router.get('/ap/users/:slug/conversations', (req, res) => {
-  const auth = OAuth.verifyBearer(req.headers.authorization);
-  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
-  const heads = AP.conversationHeads(auth.site.slug);
-  const pairs = conversationItems(req, auth, heads);
-  const items = pairs.map((x) => x.item);
-  // Ongelezen per gesprek (shaer-frontend-3tx): een COUNT, geen bijgehouden
-  // getal. Hij hangt aan het NIEUWSTE kopje van elke persoon -- er kunnen er
-  // twee zijn (zie conversationHeads) en het aantal hoort bij het gesprek, niet
-  // bij een bericht.
-  //
-  // AS2 heeft geen term voor ongelezen; dit is per-lezer-interactiestatus,
-  // dezelfde categorie als shaer:liked. Niet in totalItems persen: dat betekent
-  // 'hoeveel er zijn' en niet 'hoeveel jij nog niet zag'.
-  // De poorten van DEZE lezer, niet die van de inbox-handler: die leeft in een
-  // andere functie en heette hier per ongeluk P.
-  const poorten = gatesFor(auth.site);
-  const ongelezen = AP.unreadPerConversation(auth.site.slug, {
-    messagesAllowed: poorten.messagesAllowed,
-    guardians: (() => { try { return new Set(Guardianship.listGuardians(auth.site.slug).map((g) => g.other_uri)); } catch { return new Set(); } })(),
-  });
-  const gezien = new Set();
-  for (const { head, item } of pairs) {
-    if (gezien.has(head.other)) continue;
-    gezien.add(head.other);
-    const u = ongelezen.get(head.other);
-    if (!u) continue;
-    item.object['shaer:unread'] = u.n;
-    // Een zwaai is geen aantal maar een zetje van een guardian: eigen teken.
-    if (u.wave) item.object['shaer:unreadWave'] = true;
-  }
-  AP.sendAP(res, {
-    '@context': AP.AP_CONTEXT,
-    id: `${baseUrl(req)}/ap/users/${encodeURIComponent(auth.site.slug)}/conversations`,
-    type: 'OrderedCollection',
-    totalItems: items.length,
-    orderedItems: items,
-    'shaer:cursor': AP.feedCursor(auth.site.slug),
-  }, 'private, no-store');
-});
-
-router.get('/ap/users/:slug/messages', (req, res) => {
-  const auth = OAuth.verifyBearer(req.headers.authorization);
-  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
-  const other = String(req.query.with || '');
-  if (!/^https?:\/\//i.test(other)) return res.status(400).json({ error: 'with must be an actor URI' });
-  const page = AP.conversationHistory(auth.site.slug, other, {
-    before: req.query.before ? String(req.query.before) : null,
-    limit: req.query.limit,
-  });
-  const items = conversationItems(req, auth, page.rows).map((x) => x.item);
-  // De paginagrootte reist mee in next: vroeg je om 30, dan hoort de volgende
-  // pagina er ook 30 te zijn. Zonder dit wordt hij stilletjes de standaard, en
-  // dan klopt het ritme van een 'load more' niet meer met wat de gebruiker ziet.
-  const size = req.query.limit ? `&limit=${encodeURIComponent(String(req.query.limit))}` : '';
-  const self = `${baseUrl(req)}/ap/users/${encodeURIComponent(auth.site.slug)}/messages?with=${encodeURIComponent(other)}`;
-  AP.sendAP(res, {
-    '@context': AP.AP_CONTEXT,
-    id: req.query.before ? `${self}${size}&before=${encodeURIComponent(String(req.query.before))}` : `${self}${size}`,
-    type: 'OrderedCollectionPage',
-    partOf: self,
-    orderedItems: items,
-    // De volgende pagina is de standaardvorm van 'er is meer' (AS2). Ontbreekt
-    // hij, dan is het gesprek op -- en dat mag de client weten zonder gokken,
-    // want anders kan een 'load more' niet eerlijk verschijnen.
-    next: page.more && page.oldest ? `${self}${size}&before=${encodeURIComponent(page.oldest)}` : undefined,
-  }, 'private, no-store');
-});
-
-// De bel (/inbox/wait) is weg (shaer-pq4, 10-8). Hij deed hetzelfde als de
-// WACHTENDE inbox-lezing hierboven, maar in twee rondjes in plaats van een:
-// eerst 'er is nieuws', dan alsnog de lezing. Die lezing kan het zelf, en
-// sinds ?changes=1 stuurt hij alleen nog het verschil.
-//
-// AP.onNews blijft bestaan: de Guardian-PWA hangt er ook aan.
-
-// The server blocklist is the source of truth for Shaer's "in Orbit":
-// clients read it here instead of keeping their own state. Actor-kind
-// blocks only (domain blocks are instance policy, not an Orbit member).
-//
-// FEP-1580 zet deze deur één spleet verder open: de bronkant MOET de blokkades
-// beschikbaar maken voor de instantie waar je NAARTOE verhuist, zodat je
-// zichtbaarheidsvoorkeuren meeverhuizen. De doelkant haalt ze als eerste op,
-// want ze bepalen wat de rest te zien krijgt. Geen nieuwe collectie: deze
-// bestond al en staat al op de actor, alleen de toegang verbreedt.
-router.get('/ap/users/:slug/blocked', async (req, res) => {
-  const auth = OAuth.verifyBearer(req.headers.authorization);
-  let slug = (auth && auth.site.slug === req.params.slug) ? auth.site.slug : null;
-  if (!slug && req.headers['signature']) {
-    const verified = await AP.verifyRequest(req).catch(() => null);
-    if (verified && verified.id && AP.isMoveTarget(req.params.slug, verified.id)) slug = req.params.slug;
-  }
-  if (!slug) return res.status(403).end();
-  const base = baseUrl(req);
-  const items = AP.listBlocks(slug)
-    .filter((b) => b.kind === 'actor')
-    .map((b) => b.target);
-  AP.sendAP(res, {
-    '@context': AP.AP_CONTEXT,
-    id: `${base}/ap/users/${slug}/blocked`,
-    type: 'OrderedCollection',
-    totalItems: items.length,
-    orderedItems: items,
-  }, 'private, no-store');
-});
-
-// ── Guardian queues (owner only, FEP-633c, shaer:queues) ──────────
-// The dashboard collections the Shaer clients read: pending adoption offers,
-// gated follows (empty in Klonkt for now) and the guardian's wards. Same
-// contract as the Shaer test daemon.
-function queueRoute(name, build) {
-  router.get(`/ap/users/:slug/queues/${name}`, (req, res) => {
-    const auth = OAuth.verifyBearer(req.headers.authorization);
-    if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
-    const base = baseUrl(req);
-    const me = `${base}/ap/users/${auth.site.slug}`;
-    // 304 als er niets veranderde (Barts punt, 9-8). Zonder dit haalde een app
-    // bij elke actie de hele lijst opnieuw op -- een hulpvraag afvinken vroeg de
-    // honderd wards inclusief poorten terug.
-    AP.sendMaybe304(req, res, { '@context': AP.AP_CONTEXT, ...build(`${me}/queues/${name}`, auth.site.slug, me) });
-  });
-}
-queueRoute('offers', (id, slug, me) => Guardianship.offersCollection(id, slug, me));
-queueRoute('follows', (id, slug, me) => Guardianship.followsCollection(id, slug, me));
-// §5.3 turned around (shaer-p729): what this ward has asked to follow, still
-// waiting on its guardians. Owner-only like the rest — who a child wants to
-// follow is nobody else's business.
-queueRoute('outgoing-follows', (id, slug, me) => Guardianship.outgoingFollowsCollection(id, slug, me));
-queueRoute('wards', (id, slug) => Guardianship.wardsCollection(id, slug));
-// Availability (FEP-633c 3.6.1) is never public: the ward reads its
-// guardians' real states here and nowhere else.
-queueRoute('guardians', (id, slug) => Guardianship.guardiansCollection(id, slug));
-
-// ── Het logboek (FEP-633c §4.2, shaer:log) ────────────────────────────
-// NAAST de wachtrijen en niet erin: alles onder shaer:queues wacht op een
-// antwoord, dit is wat er al besloten is. Eigen pad, dezelfde eigenaar-only
-// bearer. Het bestaat omdat een weigering anders alleen te merken viel doordat
-// er iets uit een lijst verdween, en "het is weg" is geen reden.
-router.get('/ap/users/:slug/log', (req, res) => {
-  const auth = OAuth.verifyBearer(req.headers.authorization);
-  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
-  const me = `${baseUrl(req)}/ap/users/${auth.site.slug}`;
-  AP.sendAP(res, {
-    '@context': AP.AP_CONTEXT,
-    ...Guardianship.logCollection(`${me}/log`, auth.site.slug, (s) => AP.listGuardianEvents(s, 50)),
-  }, 'private, no-store');
-});
-// De hulpvragen MET hun staat (5.2.1, shaer-lgo). De apps lazen ze uit de feed
-// en wisten dus niet of er al iemand op af was -- daarom bleef een afgehandeld
-// verzoek daar staan (Barts melding, 8-8).
-queueRoute('help', (id, slug) => Guardianship.helpCollection(id, slug));
-
-// ── Inbox read (owner only, AP C2S) ───────────────────────────────
-// GET on the inbox is part of ActivityPub C2S: the account owner (a bearer
-// scoped to this site) reads recent inbound posts (the timeline: accounts
-// they follow) as Create(Note) items, so an app (Shaer) can build a unified
-// feed. Anyone else gets 403; the inbox stays write-only for the public.
-router.get('/ap/users/:slug/inbox', async (req, res) => {
-  const auth = OAuth.verifyBearer(req.headers.authorization);
-  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
-  const base = baseUrl(req);
-  // Wachten is een UITBREIDING van deze lezing, geen tweede endpoint (shaer-n05).
-  // Geef `since` (de shaer:cursor van je vorige antwoord) en `wait` mee, en het
-  // antwoord blijft hangen tot er iets is of de tijd om is. Zonder die twee
-  // gedraagt de route zich exact zoals altijd.
-  //
-  // Bewust hetzelfde antwoord in plaats van een "er is nieuws"-seintje: dan
-  // hoeft er niets nieuws geparsed te worden, is er geen tweede beschrijving van
-  // de kaartvorm die uit de pas kan lopen, en scheelt het de client een tweede
-  // ronde.
-  const wachtS = Math.min(Math.max(parseInt(req.query.wait, 10) || 0, 0), 50);
-  if (req.query.since && wachtS > 0) {
-    const afbreken = new AbortController();
-    res.on('close', () => afbreken.abort());   // client hing op: niet doorgaan met wachten
-    const uit = await AP.waitForFeedChange(auth.site.slug, {
-      since: String(req.query.since), waitMs: wachtS * 1000, signal: afbreken.signal,
-    });
-    if (res.writableEnded || afbreken.signal.aborted) return undefined;
-    // Niets veranderd? Dan een LEEG antwoord (Barts punt): de hele collectie
-    // terugsturen terwijl er niets gebeurd is, is elke 25 seconden een tijdlijn
-    // over de mobiele verbinding voor niets. Met 304 kost stilte niets en kost
-    // nieuws nog steeds maar één rondje -- beter dan een apart seintje-endpoint,
-    // dat voor nieuws twee rondjes nodig heeft.
-    //
-    // De '0'-uitzondering is geen franje. Ontbreekt ap_feed_state (een instance
-    // die de migratie nog niet draaide), dan geeft feedCursor altijd '0' terug,
-    // en zou een client hier eeuwig 304 krijgen en nooit meer inhoud zien. Bij
-    // een lege merksteen sturen we dus gewoon de collectie.
-    if (!uit.changed && uit.cursor !== '0') {
-      res.set('Vary', 'Authorization');
-      return res.status(304).end();
-    }
-  }
-  // Gated feature (FEP-633c): may this account see EXTERNAL embeds? A ward's
-  // world outside the fediverse is the guardians' call. The gate is applied
-  // here, at serialisation: a blocked embed is never sent, because an embed the
-  // client merely hides has still been delivered to the device.
-  // De poorten van deze lezer (gatesFor): een plek waar ze berekend worden,
-  // zodat de gesprekslezingen dezelfde stand eerbiedigen en niet hun eigen
-  // kopie krijgen die kan gaan afwijken.
-  const P = gatesFor(auth.site);
-  const {
-    embedsAllowed, playbackAllowed, imagesAllowed, musicAllowed, quotesAllowed,
-    emojiAllowed, messagesAllowed, composeAllowed, repliesAllowed, threadsAllowed,
-    followingAllowed, gateAuthor,
-  } = P;
-  // De rechten-lijst hieronder vraagt er nog een paar rechtstreeks op.
-  const gate = (col) => Guardianship.wardGateAllowed(auth.site[col], P.isWard);
-  // ── Standaardvormen naast het dialect (shaer-nmw) ────────────────
-  //
-  // Een lezer die AS2 kent heeft nu genoeg aan attributedTo (ingesloten
-  // actor), quote (FEP-044f als object), preview (AS2 core) en de
-  // Announce-wrapper. De shaer:-velden blijven er nog naast staan voor apps
-  // in het veld; die gaan eruit als de clients om zijn.
-  // Wie ik ben en wie mijn guardians zijn: allebei de lezingen hieronder
-  // hebben ze nodig, dus een keer, hierboven.
-  const me = AP.actorId(base, auth.site.slug);
-  const myHandle = AP.deriveHandle(me);   // een naam, of de kale URI -- nooit een halve
-  const guardianUris = (() => { try { return new Set(Guardianship.listGuardians(auth.site.slug).map((g) => g.other_uri)); } catch { return new Set(); } })();
-  // ── Alleen het VERSCHIL, als de client daarom vraagt (shaer-pq4) ──
-  //
-  // De wachtende lezing zei tot nu toe alleen DAT er iets veranderde, waarna de
-  // client alles opnieuw las: vier legs van zestig met al hun media-, quote- en
-  // embed-JSON, voor een enkel nieuw bericht. ap_feed_state houdt per object al
-  // bij wat er wanneer veranderde, dus het verschil lag er klaar en werd alleen
-  // nooit uitgedeeld (feedChangesSince had geen enkele aanroeper).
-  //
-  // OPT-IN met ?changes=1, en dat is geen franje: een app in het veld stuurt
-  // `since` al mee en vervangt haar hele feed door wat er terugkomt. Zou
-  // `since` opeens een verschil betekenen, dan wist die app zichzelf leeg.
-  //
-  // Het antwoord is een OrderedCollectionPage met partOf, want dat is wat het
-  // IS -- een deel, geen collectie. Een generieke lezer ziet dat verschil ook.
-  if (req.query.changes && req.query.since) {
-    const veranderd = AP.feedChangesSince(auth.site.slug, String(req.query.since));
-    const levend = veranderd.filter((c) => c.kind !== 'deleted').map((c) => c.object_uri);
-    const tl = new Map(AP.timelineRowsByIds(auth.site.slug, levend).map((r) => [r.id, r]));
-    const mn = new Map(AP.messageRowsByUri(auth.site.slug, levend.filter((u) => !tl.has(u))).map((r) => [r.object_uri, r]));
-    const rp = new Map(AP.replyRowsByUri(auth.site.slug, levend.filter((u) => !tl.has(u) && !mn.has(u))).map((r) => [r.object_uri, r]));
-    const reacties = AP.getReactionsFor(auth.site.slug, [...tl.keys()]);
-    const ctx = { base, me, myHandle, p: P };
-    const items = [];
-    for (const c of veranderd) {
-      if (c.kind === 'deleted') {
-        // Een verwijdering reisde tot nu toe als AFWEZIGHEID mee: de volledige
-        // lezing bevatte hem simpelweg niet meer. Die volledigheid is precies
-        // wat hier wegvalt, dus zonder grafsteen zou een weggehaalde post voor
-        // altijd in de app blijven staan -- en dat faalt stil. AS2 heeft er een
-        // vorm voor, en de rij lag er al.
-        items.push({ type: 'Delete', actor: me, object: { id: c.object_uri, type: 'Tombstone' } });
-        continue;
-      }
-      const t = tl.get(c.object_uri);
-      if (t) { items.push(timelineItem(t, { p: P, reactions: reacties })); continue; }
-      const m = mn.get(c.object_uri);
-      if (m) {
-        if (messagesAllowed || m.help_request || guardianUris.has(m.actor_uri)) items.push(messageItem(m, ctx));
-        continue;
-      }
-      const r = rp.get(c.object_uri);
-      if (r) { items.push(replyItem(r, ctx)); continue; }
-      const n = AP.getOutboxNote(base, c.object_uri);
-      if (n) items.push(sentItem(n, { me, mine: AP.selfAuthor(base, auth.site) }));
-    }
-    return AP.sendAP(res, {
-      '@context': AP.AP_CONTEXT,
-      id: `${base}/ap/users/${encodeURIComponent(auth.site.slug)}/inbox?changes=1&since=${encodeURIComponent(String(req.query.since))}`,
-      type: 'OrderedCollectionPage',
-      partOf: `${base}/ap/users/${auth.site.slug}/inbox`,
-      orderedItems: items,
-      // De rechten gaan MEE. Zonder dit valt de client terug op zijn standaard,
-      // en die standaard is 'alles mag' -- dan zet een gesloten poort zichzelf
-      // stil open bij elke verschil-lezing. Dezelfde reden waarom een 304 de
-      // caps met rust laat.
-      'shaer:capabilities': capabilitiesOf(P, gate),
-      'shaer:cursor': AP.feedCursor(auth.site.slug),
-    }, 'private, no-store');
-  }
-  const rows = AP.getTimeline(auth.site.slug, 60);
-  // Eén query voor de hele pagina (shaer-9e9 fase 2): shaer:liked komt uit de
-  // tussentabel, de bron van waarheid, en niet meer uit de afgeleide kolom op
-  // ap_timeline. Per rij vragen zou hier een N+1 opleveren.
-  const reacties = AP.getReactionsFor(auth.site.slug, rows.map((t) => t.id));
-  const posts = rows.map((t) => timelineItem(t, { p: P, reactions: reacties }));
-  // The direct notes addressed to this account: a plain DM, a guardian's wave
-  // (§5), a ward's 🛟 help request (§5.2.1). Those are messages, not posts, so
-  // they are not in the timeline; without them the app's Berichten shows only
-  // what you said yourself. Same shape as a post, so one parser handles both.
-  // Messages dicht (shaer-3ow) sluit vreemden en vrienden, maar NOOIT het
-  // guardian-kanaal: de zwaai en het gesprek na een hulpvraag zijn precies
-  // het kanaal dat het kind veilig houdt, en een poort die dat afsnijdt
-  // beschermt niemand. De hulpvraag zelf gaat aan de innamekant al altijd voor.
-  const messageCtx = { base, me, myHandle, p: P };
-  const messages = AP.getDirectMessages(auth.site.slug, 60)
-    .filter((m) => messagesAllowed || m.help_request || guardianUris.has(m.actor_uri))
-    .map((m) => messageItem(m, messageCtx));
-  // Inbound REPLIES on your own posts: stored as interactions (the web's
-  // comment machinery), never as mentions, so this read missed them and a
-  // friend's reply arrived everywhere except in your app (Robins melding,
-  // 30-7). Same shape as the other legs; media/quotes ride the stored JSON.
-  const replies = AP.getReplyMessages(auth.site.slug, 60).map((m) => replyItem(m, messageCtx));
-  // Your OWN sent notes (replies and direct messages, ap_outbox): without
-  // them a reply existed everywhere except in your own app, Messages showed
-  // half a conversation, and a retry ran into the duplicate guard (Robins
-  // melding, 30-7). Served like the other legs: same shape, one parser.
-  const mine = AP.selfAuthor(base, auth.site);
-  const sent = AP.getSentNotes(base, auth.site, 60).map((n) => ({
-    id: `${n.id}#create`,
-    type: 'Create',
-    actor: me,
-    published: n.published,
-    // The leading mention anchor is addressing, not prose (the DM leg strips
-    // it the same way); the Mention tags built from the full content stay.
-    object: {
-      ...n, content: AP.stripLeadingMentions(n.content),
-      attributedTo: AP.actorObject(typeof n.attributedTo === 'string' ? n.attributedTo : me, mine),
-    },
-  }));
-  // Newest first over all legs, so the app can keep treating this as one feed.
-  const items = [...posts, ...messages, ...replies, ...sent].sort((a, b) => String(b.published || '').localeCompare(String(a.published || '')));
-  AP.sendAP(res, {
-    '@context': AP.AP_CONTEXT,
-    id: `${base}/ap/users/${auth.site.slug}/inbox`,
-    type: 'OrderedCollection',
-    // What this account may do with what is in here (FEP-633c 5.6). Owner-only
-    // by construction, and never on the public actor document: it says
-    // something about a child, and only the child and its guardians need it.
-    'shaer:capabilities': capabilitiesOf(P, gate),
-    // Het merk van wat hierin zit. Geef hem terug als `since` om op het
-    // volgende te wachten. NA het samenstellen bepaald, zodat hij precies dekt
-    // wat je in handen hebt en niet iets dat er ondertussen bij kwam.
-    'shaer:cursor': AP.feedCursor(auth.site.slug),
-    totalItems: items.length,
-    orderedItems: items,
-  });
-  return undefined;
-});
-
-// ── uploadMedia (owner only, AP C2S) ──────────────────────────────
-// The actor advertises endpoints.uploadMedia; this implements it. A bearer
-// scoped to this site uploads one image/audio/video (multipart field "file",
-// AP convention) into the same store the reply editor uses, and gets back
-// { url, mediaType, name } to attach on a note (e.g. the help-buoy capture).
-const AP_MEDIA_DIR = mediaDir('REPLY_MEDIA_PATH', 'reply-media');
-fs.mkdirSync(AP_MEDIA_DIR, { recursive: true });
-const AP_MEDIA_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.mp3', '.m4a', '.ogg', '.opus', '.flac', '.wav', '.mp4', '.webm', '.mov']);
-const apMediaUpload = multer({
-  storage: multer.diskStorage({
-    destination: (req, file, cb) => cb(null, AP_MEDIA_DIR),
-    filename: (req, file, cb) => cb(null, `${randomUUID()}${path.extname(file.originalname || '').toLowerCase()}`),
-  }),
-  limits: { fileSize: 32 * 1024 * 1024 },
-  fileFilter: (req, file, cb) => {
-    const ext = path.extname(file.originalname || '').toLowerCase();
-    if (!AP_MEDIA_EXT.has(ext)) return cb(new Error('Media must be an image, audio or video file'));
-    cb(null, true);
-  },
-});
-router.post('/ap/users/:slug/uploadMedia', (req, res) => {
-  const auth = OAuth.verifyBearer(req.headers.authorization);
-  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
-  apMediaUpload.single('file')(req, res, (err) => {
-    if (err) return res.status(400).json({ error: err.message });
-    if (!req.file) return res.status(400).json({ error: 'No file' });
-    const mime = String(req.file.mimetype || '');
-    if (!/^(image|audio|video)\//.test(mime)) {
-      try { fs.unlinkSync(req.file.path); } catch { /* best effort */ }
-      return res.status(400).json({ error: 'Media must be an image, audio or video file' });
-    }
-    // A video gets a poster frame next to it (shaer-zowq), best-effort and
-    // out of band: ffmpeg pulls one frame at 1s into <name>.poster.jpg. On a
-    // machine without ffmpeg nothing happens and nothing breaks; the clients
-    // fall back to extracting a frame natively.
-    if (mime.startsWith('video/')) {
-      // The bundled static build (ffmpeg-static) does the work, exactly like
-      // VideoCoverService and AudioTranscoder already do: Klonkt SHIPS its
-      // ffmpeg (Robins opmerking, 30-7), so nothing needs installing on any
-      // machine. Soft dependency + best-effort: absent stays silent, and
-      // FFMPEG_PATH can still override for an operator who wants a newer one.
-      Promise.all([import('child_process'), import('ffmpeg-static')]).then(([{ execFile }, ff]) => {
-        const bin = process.env.FFMPEG_PATH || ff.default;
-        if (!bin) return;
-        const poster = req.file.path + '.poster.jpg';
-        execFile(bin, ['-hide_banner', '-loglevel', 'error', '-y', '-ss', '1', '-i', req.file.path, '-frames:v', '1', '-vf', "scale='min(640,iw)':-2", poster],
-          { timeout: 30000 }, (e) => { if (e && e.code !== 'ENOENT') console.warn('[media] poster failed:', e.message); });
-      }).catch(() => { /* never blocks the upload */ });
-    }
-    // Audio gets the same courtesy (Robins vraag, 30-7: vrolijk de kale
-    // audio-tegel op): ffmpeg draws the waveform into <name>.poster.png.
-    // White on transparent, so the tile's own gradient stays the backdrop
-    // and every audio post keeps its own hue. The shape is bars, not the
-    // raw hairy wave (Robins tweede vraag): peak and average sampled into
-    // 57 columns (soft tip over bright core), blown up nearest-neighbor to
-    // 14px bars, and drawgrid ERASES 5px gaps (c=black@0 + replace=1 writes
-    // transparent pixels; h=2*ih keeps horizontal grid lines out of frame).
-    if (mime.startsWith('audio/')) {
-      Promise.all([import('child_process'), import('ffmpeg-static')]).then(([{ execFile }, ff]) => {
-        const bin = process.env.FFMPEG_PATH || ff.default;
-        if (!bin) return;
-        const poster = req.file.path + '.poster.png';
-        const graph = '[0:a]aformat=channel_layouts=mono,asplit[a][b];'
-          + '[a]showwavespic=s=57x256:colors=white@0.5:filter=peak:scale=sqrt:draw=full[pk];'
-          + '[b]showwavespic=s=57x256:colors=white:filter=average:scale=sqrt:draw=full[av];'
-          + '[pk][av]overlay=format=auto,scale=798:256:flags=neighbor,drawgrid=w=14:h=2*ih:t=5:c=black@0:replace=1';
-        execFile(bin, ['-hide_banner', '-loglevel', 'error', '-y', '-i', req.file.path, '-filter_complex', graph, '-frames:v', '1', poster],
-          { timeout: 30000 }, (e) => { if (e && e.code !== 'ENOENT') console.warn('[media] waveform failed:', e.message); });
-      }).catch(() => { /* never blocks the upload */ });
-    }
-    res.status(201).json({
-      url: '/media/reply-media/' + req.file.filename,
-      mediaType: mime,
-      name: String(req.file.originalname || '').slice(0, 120),
-    });
-  });
-});
-
-// ── Followers (count-only public, full for the owner) ─────────────
-// A C2S bearer scoped to this site (the account owner) gets the real actor
-// URIs so their own client can build a friends list; everyone else gets the
-// count only (privacy).
-// FEP-9876: enrichment is opt-in via `Prefer: return=representation` (RFC 7240).
-// Returns true and sets the response headers when the owner asked for it.
-function wantsEnriched(req, res) {
-  res.set('Vary', 'Prefer');   // enriched and bare are two representations
-  if (AP.prefersEnriched(req.get('Prefer'))) {
-    res.set('Preference-Applied', 'return=representation');
-    return true;
-  }
-  return false;
-}
-
-router.get('/ap/users/:slug/followers', (req, res) => {
-  const auth = OAuth.verifyBearer(req.headers.authorization);
-  const owner = auth && auth.site.slug === req.params.slug;
-  const site = owner ? auth.site : publicSite(req.params.slug);
-  if (!site) return res.status(404).end();
-  if (owner) {
-    const uris = db.prepare('SELECT actor_uri FROM ap_followers WHERE slug = ? ORDER BY created_at').all(site.slug).map((r) => r.actor_uri);
-    // Default = bare references; enrich only when the client asks (FEP-9876).
-    const items = wantsEnriched(req, res) ? uris.map((u) => AP.buildActorRef(site.slug, u)) : uris;
-    return AP.sendAP(res, AP.buildFollowers(baseUrl(req), site, items.length, items, { page: paginaNr(req) }));
-  }
-  const n = db.prepare('SELECT COUNT(*) n FROM ap_followers WHERE slug = ?').get(site.slug).n;
-  AP.sendAP(res, AP.buildFollowers(baseUrl(req), site, n, null, { page: paginaNr(req) }));
-});
-
-// ── Following (count-only public, full for the owner) ─────────────
-router.get('/ap/users/:slug/following', (req, res) => {
-  const auth = OAuth.verifyBearer(req.headers.authorization);
-  const owner = auth && auth.site.slug === req.params.slug;
-  const site = owner ? auth.site : publicSite(req.params.slug);
-  if (!site) return res.status(404).end();
-  if (owner) {
-    const enrich = wantsEnriched(req, res);   // FEP-9876 opt-in
-    let items = [];
-    try {
-      const uris = db.prepare("SELECT actor_uri FROM ap_following WHERE slug = ? AND status = 'accepted' ORDER BY created_at").all(site.slug).map((r) => r.actor_uri);
-      items = enrich ? uris.map((u) => AP.buildActorRef(site.slug, u)) : uris;
-    } catch { /* table may not exist */ }
-    return AP.sendAP(res, AP.buildFollowing(baseUrl(req), site, items.length, items, { page: paginaNr(req) }));
-  }
-  let n = 0;
-  try { n = db.prepare("SELECT COUNT(*) n FROM ap_following WHERE slug = ? AND status = 'accepted'").get(site.slug).n; } catch { /* table may not exist */ }
-  AP.sendAP(res, AP.buildFollowing(baseUrl(req), site, n, null, { page: paginaNr(req) }));
-});
-
-/**
- * Mag deze aanvrager alles van `slug` zien? Waar bij de eigenaar zelf, en waar
- * voor de actor waar `slug` naartoe verhuisd is (FEP-1580, Source Instance).
- *
- * Eén plek voor die vraag, want hij komt op meerdere collecties terug en twee
- * antwoorden op dezelfde vraag lopen vroeg of laat uiteen.
- */
-async function magAlles(req, slug) {
-  const auth = OAuth.verifyBearer(req.headers.authorization);
-  if (auth && auth.site.slug === slug) return true;
-  if (!req.headers['signature']) return false;
-  const v = await AP.verifyRequest(req).catch(() => null);
-  return !!(v && v.id && AP.isMoveTarget(slug, v.id));
-}
-
-// ── FEP-1580: de vertaaltabel van een verhuizing ──────────────────
-//
-// Publiek leesbaar, want dat is het hele doel: een derde die een oude URI in
-// zijn database heeft leest hier wat de nieuwe is. Zonder deze collectie blijft
-// elke reactie op een verhuisd bericht naar een dood adres wijzen.
-//
-// Niet-publieke items komen er alleen in voor een lezer die ze mocht zien. De
-// spec: Moves voor objecten die niet aan as:Public gericht zijn MOGEN NIET
-// publiek getoond worden. Een lijst met de URIs van je fan-only posts is een
-// lek, ook al staat de inhoud er niet bij.
-router.get('/ap/users/:slug/migration', async (req, res) => {
-  const site = publicSite(req.params.slug);
-  if (!site) return res.status(404).end();
-  let alles = false;
-  const auth = OAuth.verifyBearer(req.headers.authorization);
-  if (auth && auth.site.slug === site.slug) alles = true;
-  else if (req.headers['signature']) {
-    const v = await AP.verifyRequest(req).catch(() => null);
-    // Een geverifieerde volger zat in het publiek van de fan-only posts, dus
-    // die mag ook weten waar ze heen zijn.
-    if (v && v.id && AP.outboxAudience(site.slug, { verifiedActor: v.id }) === 'friend') alles = true;
-  }
-  AP.sendAP(res, Migration.buildMigration(baseUrl(req), site, { page: paginaNr(req), alles }),
-    alles ? 'private, no-store' : undefined);
-});
-
-// De Moves die de vertaaltabel rechtvaardigen. Altijd publiek: een bewijs dat
-// je moet kunnen nakijken heeft niets aan een slot.
-//
-// LET OP: zonder FEP-8b32 (shaer-j1v0) staat hier geen handtekening onder. De
-// collectie is structureel goed en niet verifieerbaar, en een derde die de spec
-// streng volgt mag hem daarom weigeren. Bewust geen leeg proof-veld erbij.
-router.get('/ap/users/:slug/moves', (req, res) => {
-  const site = publicSite(req.params.slug);
-  if (!site) return res.status(404).end();
-  AP.sendAP(res, Migration.buildMoves(baseUrl(req), site));
-});
-
-// ── Featured (pinned posts → Mastodon "Featured" tab) ─────────────
-router.get('/ap/users/:slug/featured', (req, res) => {
-  const site = publicSite(req.params.slug);
-  if (!site) return res.status(404).end();
-  // NB: Mastodon DISPLAYS the featured collection in REVERSE (pins shown
-  // last-processed-first). So we emit it reversed (lowest pin priority first,
-  // rank 1 last) → Mastodon flips it back to pin-rank ascending on the profile.
-  // fan_only, ap_visibility, paid en excerpt MOETEN mee, om dezelfde reden als
-  // in outboxSlice en backfillNewFollower (shaer-6oth, en Barts melding van
-  // 15-8). buildNote beslist op deze velden, en een ontbrekende kolom is daar
-  // `undefined` -- wat stilletjes het ruimste gedrag oplevert:
-  //
-  //   zonder `paid`    slaat buildNote zijn redactie over en gaat de VOLLEDIGE
-  //                    tekst van een betaalde post mee. Deze collectie is
-  //                    publiek en onbetekend opvraagbaar, dus dat is de post
-  //                    gewoon te lezen. `excerpt` hoort erbij, anders valt de
-  //                    teaser terug op de eerste alinea van precies de tekst
-  //                    die verborgen moet blijven.
-  //   zonder ap_vis    krijgt een quiet/unlisted post `to: as:Public` in plaats
-  //                    van zijn volgers -- luider dan de schrijver koos.
-  //
-  // Het filter erbij: fan_only stond er al, ap_visibility ontbrak. Een post die
-  // niet publiek bedoeld is hoort niet in een publieke collectie, ook niet als
-  // hij vastgezet is.
-  const posts = db.prepare(
-    `SELECT id, slug, title, content, cover_image_url, cover_video_url, nsfw, content_warning, c2s_attachments, published_at, created_at,
-            fan_only, ap_visibility, paid, paid_min_cents, excerpt
-     FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
-       AND IFNULL(ap_visibility, 'public') IN ('public', 'quiet')
-       AND pinned IS NOT NULL AND pinned > 0
-     ORDER BY pinned DESC, ${isoSql('COALESCE(published_at, created_at)')} ASC LIMIT 20`
-  ).all(site.id);
-  AP.sendAP(res, AP.buildFeatured(baseUrl(req), site, posts, { page: paginaNr(req) }));
-});
-
-// ── Playlist als dereferenceerbare AP-collectie (shaer-ayc) ───────
-// De eerste stap van het Funkwhale-spoor: een playlist heeft een id, dus een
-// stabiele URI. Alleen het fedi_open-deel staat erin (de poort is per bestand
-// en eenrichtings; zie setAudioFediOpen in routes/posts.js) — een collectie
-// zonder open tracks bestaat wel maar is leeg, want de playlist zelf is niet
-// geheim, alleen de bestanden erachter.
-// De lijst van alle playlist-collecties (shaer-ayc, stap 2). De actor wijst
-// hierheen via AS2 `streams`. Kaal standaard; verrijkte stubs op verzoek
-// (FEP-9876), dezelfde conventie als followers/following.
-router.get('/ap/users/:slug/playlists', (req, res) => {
-  const site = publicSite(req.params.slug);
-  if (!site) return res.status(404).end();
-  AP.sendAP(res, AP.listPlaylistsAP(baseUrl(req), site, wantsEnriched(req, res), { page: paginaNr(req) }));
-});
-
-// De tracks van deze site: de kanonieke plek voor onze muziek (shaer-0nh,
-// stap 3). Een playlist is een keuze hieruit; deze collectie is alles wat de
-// artiest heeft opengezet, ook wat in geen enkele playlist staat.
-router.get('/ap/users/:slug/tracks', async (req, res) => {
-  const site = publicSite(req.params.slug);
-  if (!site) return res.status(404).end();
-  AP.sendAP(res, AP.buildTrackCollection(baseUrl(req), site, AP.siteOpenTracks(site.id, { alles: await magAlles(req, site.slug) }), { page: paginaNr(req) }));
-});
-
-// De bibliotheek van deze site (shaer-0nh). Funkwhale's Audio draagt een
-// `library`, en dat is bij hen het haakje waar een UPLOAD aan komt te hangen --
-// zonder die bak blijft een binnengehaalde track daar een naam zonder geluid.
-// Gemeten op 13-8: open.audio had onze vier tracks wel, met onze eigen AP-id's,
-// maar uploads leeg en is_playable false.
-//
-// Openbaar, want alles erin is fedi_open. Er valt dus niets goed te keuren en de
-// volgerslijst blijft leeg: wie ons volgt volgt de ACTOR, niet de bak.
-//
-// `?page=` MOET hier doorgegeven worden. Zonder dat adverteert de wortel een
-// `first` die op zichzelf uitkomt: de lezer volgt hem, krijgt weer een `Library`
-// in plaats van een pagina, en klapt eruit -- open.audio gaf op 15-8 een 500 op
-// precies deze URL. Dezelfde les als bij de outbox (shaer-sk4): een `first`
-// beloven is een pagina beloven.
-router.get('/ap/users/:slug/library', (req, res) => {
-  const site = publicSite(req.params.slug);
-  if (!site) return res.status(404).end();
-  AP.sendAP(res, AP.buildLibrary(baseUrl(req), site, AP.siteOpenTracks(site.id), { page: paginaNr(req) }));
-});
-
-// De volgerscollectie die hun docs als vereist noemen. Leeg en eerlijk: er is
-// geen goedkeuringspad omdat de bibliotheek openbaar is.
-router.get('/ap/users/:slug/library/followers', (req, res) => {
-  const site = publicSite(req.params.slug);
-  if (!site) return res.status(404).end();
-  AP.sendAP(res, AP.pagedCollection(`${AP.libraryId(baseUrl(req), site)}/followers`, [], { page: paginaNr(req) }));
-});
-
-// Eén track, los op te halen. Een gesloten track is AFWEZIG, niet leeg: 404,
-// dezelfde regel als in de collectie, zodat het bestaan van een gated nummer
-// niet uit een ander antwoord af te leiden is.
-router.get('/ap/users/:slug/tracks/:id', (req, res) => {
-  const site = publicSite(req.params.slug);
-  if (!site) return res.status(404).end();
-  const row = AP.openTrack(site.id, req.params.id);
-  if (!row) return res.status(404).end();
-  AP.sendAP(res, AP.buildTrackAudio(baseUrl(req), site, row, { standalone: true }));
-});
-
-// De losse tracks van een post als EEN uitgave (shaer-38y). Ze gingen tot nu
-// toe los de deur uit -- Audio-objecten die een lezer nergens kon plaatsen. Ze
-// horen bij elkaar omdat ze in dezelfde post staan, en die post leent zijn
-// titel, tekst, hoes en tags uit. 404 als de post geen muzikale eenheid IS:
-// dan is er niets om naar te wijzen, en dat is geen lege collectie maar een
-// collectie die niet bestaat.
-router.get('/ap/users/:slug/posts/:id/tracks', (req, res) => {
-  const site = publicSite(req.params.slug);
-  if (!site) return res.status(404).end();
-  const post = db.prepare(
-    "SELECT id, slug, title, excerpt, content, cover_image_url, tags FROM posts WHERE id = ? AND site_id = ? AND status = 'published'"
-  ).get(req.params.id, site.id);
-  if (!post) return res.status(404).end();
-  const col = AP.buildPostTrackCollection(baseUrl(req), site, post);
-  if (!col) return res.status(404).end();
-  AP.sendAP(res, col);
-});
-
-router.get('/ap/users/:slug/playlists/:id', async (req, res) => {
-  const site = publicSite(req.params.slug);
-  if (!site) return res.status(404).end();
-  const pl = db.prepare('SELECT id, title, artist, year, cover_url, kind, release_date, mb_release_id, created_at FROM playlists WHERE id = ? AND site_id = ?')
-    .get(req.params.id, site.id);
-  if (!pl) return res.status(404).end();
-  // De doel-actor van een verhuizing krijgt de VOLLEDIGE plaat, niet alleen de
-  // nummers die voor de fediverse opengezet zijn (FEP-1580).
-  const alles = await magAlles(req, site.slug);
-  AP.sendAP(res, AP.buildPlaylistCollection(baseUrl(req), site, pl, AP.playlistOpenTracks(pl.id, { alles })));
-});
-
-// ── Note ──────────────────────────────────────────────────────────
-router.get('/ap/notes/:id', async (req, res) => {
-  // No fan_only filter in the SELECT anymore: a friends-only post is not
-  // absent, it is GATED. The old route hid it from EVERYONE, also from the
-  // follower whose friendship earns it — so the signed resolution the reply
-  // path performs knocked on a door that could never open, and every reply
-  // to a friends-only post (Shaer's default!) died in
-  // cannot_resolve_inReplyTo. Strangers still get the exact same 404, so a
-  // note's existence stays as private as before.
-  // EEN GEBLOKKEERDE KRIJGT DE DEUR DICHT (Robin, 21-8), net als bij de outbox:
-  // wie ondertekend aanklopt, klopt met zijn naam erop, en een blokkade is een
-  // gesloten deur. Dezelfde 404 als een vreemde, zodat het bestaan van een note
-  // niets extra's verraadt. Onbetekende verzoeken kunnen we niet thuisbrengen
-  // en houden de publieke weergave -- daarvoor is de Block-bezorging.
-  if (req.headers['signature']) {
-    const wie = await AP.verifyRequest(req).catch(() => null);
-    if (wie && wie.id && AP.isBlockedAny(wie.id)) return res.status(404).end();
-  }
-  const post = db.prepare(
-    "SELECT * FROM posts WHERE id = ? AND status = 'published'"
-  ).get(req.params.id);
-  if (post && AP.noteAudience(post) !== 'public') {
-    // The whole gate in a try: this is the only async route in this file,
-    // and Express 4 does not catch an async rejection — the request would
-    // hang forever instead of failing (which is exactly how the missing
-    // default-export entry manifested while building this). Any error here
-    // reads as "not authorized", never as silence.
-    try {
-      if (AP.noteAudience(post) === 'direct') return res.status(404).end();
-      const gsite = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id);
-      const actor = await AP.verifyRequest(req).catch(() => null);
-      if (!actor || !AP.mayReadNote(gsite, post, actor.id)) return res.status(404).end();
-    } catch { return res.status(404).end(); }
-  }
-  if (!post) {
-    // Could be one of OUR outbound replies (ap_outbox), not a post.
-    const note = AP.getOutboxNote(baseUrl(req), req.params.id);
-    if (!note) return res.status(404).end();
-    if (!AP.apWants(req)) {
-      // A browser hit a reply's AP URL → send them to the source it replies to
-      // (where the post + its reactions live), falling back to the site home.
-      const src = (typeof note.inReplyTo === 'string' && /^https?:\/\//i.test(note.inReplyTo))
-        ? note.inReplyTo : (baseUrl(req) + '/');
-      return res.redirect(302, src);
-    }
-    return AP.sendAP(res, { '@context': AP.AP_CONTEXT, ...note });
-  }
-  const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id);
-  if (!site) return res.status(404).end();
-  const note = AP.buildNote(baseUrl(req), site, post);
-  if (!AP.apWants(req)) {
-    // A browser hit a post's AP note URL → send them to the human post page
-    // (which shows the post + its "from the fediverse" reactions).
-    return res.redirect(302, note.url || (baseUrl(req) + '/'));
-  }
-  AP.sendAP(res, { '@context': AP.AP_CONTEXT, ...note });
-});
-
-// ── Replies collection ── lets remote servers fetch a post's whole thread.
-// ── De composer-preview (shaer-k3f): een URL wordt alvast een kaart ──
-//
-// Bearer-only, net als de thread: dit is de eigen app die tijdens het typen
-// vraagt wat een link gaat worden. Dezelfde pijplijn als publiceren, dus de
-// preview kan niet iets beloven dat de post niet waarmaakt. De embed gaat
-// langs de eigen poort van de lezer -- een ward zonder open embeds-poort
-// krijgt in de composer geen kaart die zijn feed hem ook niet zou tonen.
-router.get('/ap/users/:slug/card', async (req, res) => {
-  const auth = OAuth.verifyBearer(req.headers.authorization);
-  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
-  const uit = await AP.previewCard(String(req.query.url || ''));
-  const isWard = (() => { try { return Guardianship.listGuardians(auth.site.slug).length > 0; } catch { return false; } })();
-  const embedsAllowed = Guardianship.externalEmbedsAllowed(auth.site.external_embeds, isWard);
-  const playback = embedsAllowed && Guardianship.externalPlaybackAllowed(auth.site.external_playback, isWard);
-  AP.sendAP(res, {
-    '@context': AP.AP_CONTEXT,
-    quote: AP.quoteObject(uit.quoteJson),
-    preview: embedsAllowed ? AP.previewObject(uit.embedJson, { playback }) : undefined,
-  }, 'private, no-store');
-});
-
-// ── De thread onder een post (shaer-tqz): ophalen, niet bewaren ────
-//
-// Bearer-only: dit is de eigen app van deze account die vraagt, nooit een
-// vreemde. Klonkt doet de ondertekende GET die de app zelf niet kan (de
-// sleutel staat hier), loopt één pagina van de replies-collectie af en geeft
-// genormaliseerde notes terug. Er wordt NIETS opgeslagen; zie getThread.
-//
-// Voor een ward geldt de veiligste stand tot shaer-vw4 beslist is: alleen
-// antwoorden uit de kring die de guardians al kennen, en shaer:hidden telt wat
-// er buiten viel. De telling staat er zodat de UI eerlijk kan zijn -- OF hij
-// getoond wordt is onderdeel van datzelfde besluit.
-router.get('/ap/users/:slug/thread', async (req, res) => {
-  const auth = OAuth.verifyBearer(req.headers.authorization);
-  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
-  const objectUri = String(req.query.object || '');
-  if (!/^https:\/\//i.test(objectUri)) return res.status(400).json({ error: 'object must be an https URI' });
-  const isWard = (() => { try { return Guardianship.listGuardians(auth.site.slug).length > 0; } catch { return false; } })();
-  const uit = await AP.getThread(auth.site.slug, objectUri);
-  if (!uit.found) {
-    // WIENS schuld is dit? De oude melding zei "jouw server kon het niet
-    // laden" terwijl onze server het prima deed en de BRON weigerde -- dat
-    // wees naar de verkeerde partij (Barts melding, 10-8: een post van een
-    // account dat hij vanochtend nog volgde, en dat nu niet meer).
-    // 401/403/404/410 is een besluit van die server; al het andere, inclusief
-    // een status die we niet eens kregen, is een storing.
-    const geweigerd = [401, 403, 404, 410].includes(uit.sourceStatus);
-    return res.status(geweigerd ? 404 : 502)
-      .json({ error: geweigerd ? 'not shared by source' : 'source unreachable', sourceStatus: uit.sourceStatus || undefined });
-  }
-  // De poortstand komt uit de kolom (shaer-9y2): expliciete 0/1 van de
-  // guardians wint, de automatiek is dicht-voor-een-ward. Dicht is de KRING,
-  // niet niets: antwoorden van al goedgekeurd volk blijven staan, en wat er
-  // buiten valt wordt geteld. Beeld, muziek en emoji gaan door dezelfde
-  // poorten als de tijdlijn -- per verzoek, buiten de threadcache om.
-  const threadsOpen = Guardianship.wardGateAllowed(auth.site.external_threads, isWard);
-  const gate2 = (col) => Guardianship.wardGateAllowed(auth.site[col], isWard);
-  const kring = threadsOpen ? { notes: uit.notes, hidden: 0 } : AP.filterThreadToCircle(auth.site.slug, uit.notes);
-  const imagesOk = gate2('gate_images'), musicOk = gate2('gate_music'), emojiOk = gate2('gate_custom_emoji');
-  uit.notes = kring.notes.map((n) => ({
-    ...n,
-    attachment: AP.gateAttachments(n.attachment, { images: imagesOk, audio: musicOk }),
-    tag: emojiOk ? n.tag : AP.stripEmojiTags(n.tag),
-    // De emoji-poort knipt in de byline zelf: FEP-9098 zit in de tag van de
-    // ingesloten actor, niet meer in een eigen emoji-kaart ernaast.
-    attributedTo: (!emojiOk && n.attributedTo && typeof n.attributedTo === 'object')
-      ? { ...n.attributedTo, tag: undefined } : n.attributedTo,
-  }));
-  uit.hidden = kring.hidden;
-  // Liked/boosted per antwoord, BUITEN de cache om: de genormaliseerde notes
-  // mogen twee minuten oud zijn, maar of JIJ iets geliked hebt hoort van nu te
-  // zijn -- anders springt het hartje terug zodra de reader opnieuw opent.
-  const reacties = AP.getReactionsFor(auth.site.slug, uit.notes.map((n) => n.id));
-  // De thread heeft al een ?object= in zijn id, dus geen ?page= erachter: die
-  // collectie is niet te pagineren zonder de vraag zelf te herhalen. Hij is
-  // owner-only en wordt door Shaer gelezen, niet door de federatie.
-  AP.sendAP(res, {
-    '@context': AP.AP_CONTEXT,
-    id: `${baseUrl(req)}/ap/users/${encodeURIComponent(auth.site.slug)}/thread?object=${encodeURIComponent(objectUri)}`,
-    type: 'OrderedCollection',
-    totalItems: uit.notes.length,
-    orderedItems: uit.notes.map((n) => ({
-      ...n,
-      'shaer:liked': !!(reacties.get(n.id) || {}).liked,
-      'shaer:boosted': !!(reacties.get(n.id) || {}).boosted,
-    })),
-    'shaer:hidden': uit.hidden || undefined,
-  }, 'private, no-store');
-});
-
-router.get('/ap/notes/:id/replies', (req, res) => {
-  const base = baseUrl(req);
-  const items = AP.getReplyUris(base, req.params.id);
-  AP.sendAP(res, AP.pagedCollection(`${base}/ap/notes/${req.params.id}/replies`, items));
-});
-
-// ── NodeInfo ── standard instance metadata so fediverse tools recognise Klonkt.
-router.get('/.well-known/nodeinfo', (req, res) => {
-  res.type('application/json');
-  res.set('Cache-Control', 'public, max-age=3600');
-  res.send(JSON.stringify({ links: [{ rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1', href: `${baseUrl(req)}/nodeinfo/2.1` }] }));
-});
-router.get('/nodeinfo/2.1', (req, res) => {
-  let users = 0; let posts = 0;
-  // "users" = public AP actors (sites), not the admin/member account rows.
-  try { users = db.prepare('SELECT COUNT(*) c FROM sites WHERE (is_public IS NULL OR is_public = 1)').get().c; } catch { /* */ }
-  try { posts = db.prepare("SELECT COUNT(*) c FROM posts WHERE status = 'published'").get().c; } catch { /* */ }
-  res.type('application/json; charset=utf-8');
-  res.set('Cache-Control', 'public, max-age=600');
-  res.send(JSON.stringify({
-    version: '2.1',
-    software: { name: 'klonkt', version: _ver, repository: 'https://github.com/roboburr/klonkt' },
-    protocols: ['activitypub'],
-    services: { inbound: [], outbound: [] },
-    openRegistrations: false,
-    usage: { users: { total: users }, localPosts: posts },
-    metadata: { nodeName: 'Klonkt' },
-  }));
-});
-
-// ── Inbox — Follow→Accept, Undo Follow (best-effort signature verify) ──
-const apJson = express.json({
-  type: ['application/activity+json', 'application/ld+json', 'application/json'],
-  limit: '1mb',
-  verify: (req, _res, buf) => { req.rawBody = buf; }, // raw body for digest verification
-});
-router.post(['/ap/users/:slug/inbox', '/ap/inbox'], apInboxLimiter, apJson, async (req, res) => {
-  try { return res.status(await AP.handleInbox(req, req.params.slug || null) || 202).end(); }
-  // Met de STACK erbij. Hier stond alleen `e.message`, en op 15-8 leverde dat
-  // zes keer "[AP inbox] error: slug is not defined" op zonder één aanwijzing
-  // waar -- een ReferenceError in een handler van duizenden regels, met een
-  // naam die overal voorkomt. Een fout die je niet kunt plaatsen is niet
-  // gemeld. Het type en de activiteit erbij, want dat zegt welke tak liep.
-  catch (e) {
-    const soort = req.body && req.body.type;
-    console.warn('[AP inbox] error:', e.message, '| type:', soort, '| slug:', req.params.slug || '(gedeeld)');
-    console.warn(e.stack);
-    return res.status(202).end();
-  }
-});
-
-// ── Outbox POST: ActivityPub Client-to-Server ─────────────────────
-// A bearer-authenticated client (Shaer) POSTs an activity; we translate it onto
-// the normal delivery machinery. The token is scoped to one user+site (OAuth
-// consent), so it must match the slug in the URL. (Declared after apJson, which
-// this shares with the inbox handler.)
-router.post('/ap/users/:slug/outbox', apInboxLimiter, apJson, async (req, res) => {
-  const auth = OAuth.verifyBearer(req.headers.authorization);
-  if (!auth) { res.set('WWW-Authenticate', 'Bearer'); return res.status(401).json({ error: 'invalid_token' }); }
-  if (auth.site.slug !== req.params.slug) return res.status(403).json({ error: 'wrong_site', detail: 'token is scoped to a different site' });
-  if (auth.user.readonly) return res.status(403).json({ error: 'read_only_account' });
-
-  const out = await AP.ingestOutboxActivity(auth.site, auth.user, req.body);
-  if (out.error) return res.status(out.status || 400).json({ error: out.error, detail: out.detail });
-  // 201 Created → Location header (AP spec); 202 Accepted for side-effect verbs.
-  if (out.status === 201 && out.url) res.set('Location', out.url);
-  // `state` carries a third outcome the app must be able to tell apart from a
-  // plain success: a ward's follow held for its guardians (§5.3, shaer-p729).
-  return res.status(out.status || 202).json({ ok: true, id: out.id, url: out.url, ...(out.state ? { state: out.state } : {}) });
-});
-
-export default router;
Index: src/routes/admin-audio.js
===================================================================
--- src/routes/admin-audio.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/routes/admin-audio.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -18,10 +18,7 @@
 import db from '../config/database.js';
 import { renderPage } from '../middleware/render.js';
-import { toWebp } from '../services/ImageWebpService.js';
 import { requireGod } from '../middleware/auth.js';
-import { transcodeToMp3, retagMp3 } from '../services/AudioTranscoder.js';
-import { audioUrl } from '../services/AudioStreamService.js';
-import { mediaDir } from '../config/paths.js';
-import * as ActivityPubService from '../services/ActivityPubService.js';
+import { transcodeToMp3 } from '../services/AudioTranscoder.js';
+import { signUrl } from '../services/AudioStreamService.js';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -32,5 +29,7 @@
   process.env.AUDIO_PATH || path.join(__dirname, '..', '..', 'storage', 'audio')
 );
-const COVER_DIR = mediaDir('COVER_PATH', 'audio-covers');
+const COVER_DIR = path.resolve(
+  process.env.COVER_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'audio-covers')
+);
 fs.mkdirSync(AUDIO_DIR, { recursive: true });
 fs.mkdirSync(COVER_DIR, { recursive: true });
@@ -38,11 +37,6 @@
 const ALLOWED_AUDIO_EXT = new Set(['.mp3', '.m4a', '.mp4', '.aac', '.oga', '.ogg', '.opus', '.flac', '.wav', '.webm']);
 const ALLOWED_COVER_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
-const MAX_AUDIO_BYTES = 50 * 1024 * 1024;   // 50 MB — compressed formats (mp3/m4a/ogg/…)
-const MAX_WAV_BYTES   = 100 * 1024 * 1024;  // 100 MB — WAV is uncompressed, so a higher limit
-const MAX_COVER_BYTES = 5 * 1024 * 1024;    // 5 MB
-
-// Per-file upper limit based on extension. multer's global limit is the
-// highest (WAV); the real per-type check happens in the upload handler.
-const audioByteLimitFor = (ext) => (ext.toLowerCase() === '.wav' ? MAX_WAV_BYTES : MAX_AUDIO_BYTES);
+const MAX_AUDIO_BYTES = 50 * 1024 * 1024;  // 50 MB
+const MAX_COVER_BYTES = 5 * 1024 * 1024;   // 5 MB
 
 // Multer routes audio + cover into separate dirs based on field name.
@@ -59,5 +53,5 @@
 const upload = multer({
   storage,
-  limits: { fileSize: MAX_WAV_BYTES }, // highest upper bound (WAV) — per-type check in the handler
+  limits: { fileSize: MAX_AUDIO_BYTES }, // upper bound — per-field check below
   fileFilter: (req, file, cb) => {
     const ext = path.extname(file.originalname).toLowerCase();
@@ -73,21 +67,4 @@
 const router = express.Router();
 
-// "Open in" platform links per track: only https + the correct host accepted
-// (href arrives unescaped in the view → scheme/host guard against abuse).
-const LINK_DOMAINS = {
-  spotify: ['spotify.com'],
-  youtube: ['youtube.com', 'youtu.be', 'music.youtube.com'],
-  soundcloud: ['soundcloud.com'],
-};
-function platformLink(url, domains) {
-  const u = String(url || '').trim();
-  if (!u || !/^https:\/\//i.test(u)) return null;
-  try {
-    const h = new URL(u).hostname.toLowerCase();
-    if (domains.some((d) => h === d || h.endsWith('.' + d))) return u;
-  } catch (e) { /* invalid URL */ }
-  return null;
-}
-
 router.get('/', requireGod, (req, res) => {
   const site = res.locals.site;
@@ -96,30 +73,26 @@
   const rows = db.prepare(`
     SELECT t.id, t.title, t.artist, t.album, t.duration, t.cover_url,
-           t.position, t.created_at, t.downloadable, m.filename, m.size, m.mime_type
+           t.position, t.created_at, m.filename, m.size, m.mime_type
     FROM audio_tracks t
     LEFT JOIN media m ON m.id = t.media_id
     WHERE t.site_id = ?
-    ORDER BY t.created_at DESC, t.position DESC
+    ORDER BY t.position ASC, t.created_at ASC
   `).all(site.id);
 
-  // Build each track's stream URL so admins can preview audio inline.
+  // Sign each track's stream URL so admins can preview audio inline.
+  // Short TTL (default 10 min from AudioStreamService) means the URL on
+  // the page expires if it sits open too long; a refresh re-signs.
   const tracks = rows.map(t => ({
     ...t,
-    stream_url: t.filename ? audioUrl(t.filename) : null,
+    stream_url: t.filename ? signUrl(t.filename).url : null,
   }));
 
-  const base = (process.env.PUBLIC_BASE_URL || ('https://' + (req.get('host') || ''))).replace(/\/$/, '');
-  const embedUrl = base + (res.locals.siteUrlBase || '') + '/embed';
   renderPage(req, res, 'pages/admin-audio', {
-    // admin-audio neemt de track-editor op, dus die module hoort erbij.
-    pageJs: 'admin-audio track-editor',
-    pageTitleKey: 'admin.t_audio',
+    pageTitle: 'Audio tracks',
     bodyClass: 'on-admin',
     tracks,
-    embedUrl,
     error: req.query.error || null,
     success: req.query.success || null,
     maxBytesMb: Math.round(MAX_AUDIO_BYTES / 1024 / 1024),
-    maxWavMb: Math.round(MAX_WAV_BYTES / 1024 / 1024),
   });
 });
@@ -148,14 +121,4 @@
       if (coverFile) try { fs.unlinkSync(coverFile.path); } catch {}
       return fail(400, 'missing audio file');
-    }
-
-    // Per-type audio size check. multer's global limit was the WAV upper bound
-    // (100MB); compressed formats stay at 50MB.
-    const audioExt = path.extname(audioFile.originalname).toLowerCase();
-    const audioLimit = audioByteLimitFor(audioExt);
-    if (audioFile.size > audioLimit) {
-      try { fs.unlinkSync(audioFile.path); } catch {}
-      if (coverFile) try { fs.unlinkSync(coverFile.path); } catch {}
-      return fail(400, `audio te groot (max ${Math.round(audioLimit / 1024 / 1024)}MB voor ${audioExt || 'dit type'})`);
     }
 
@@ -187,11 +150,4 @@
     const finalArtist = artist?.trim() || null;
     const finalAlbum  = album?.trim() || null;
-    // Ownership/licence. credit falls back to the artist; these go both into the
-    // DB and into the ID3 tags of the mp3 (copyright + comment).
-    const finalCredit  = (req.body.credit  || '').trim() || finalArtist || null;
-    const finalLicense = (req.body.license || '').trim() || null;
-    const finalLinkSpotify    = platformLink(req.body.link_spotify, LINK_DOMAINS.spotify);
-    const finalLinkYoutube    = platformLink(req.body.link_youtube, LINK_DOMAINS.youtube);
-    const finalLinkSoundcloud = platformLink(req.body.link_soundcloud, LINK_DOMAINS.soundcloud);
 
     console.log('[admin-audio] upload received:', {
@@ -212,6 +168,4 @@
           artist: finalArtist || undefined,
           album: finalAlbum || undefined,
-          copyright: finalCredit || undefined,
-          comment: finalLicense || undefined,
         },
       });
@@ -233,17 +187,8 @@
       `).run(mediaId, site.id, transcoded.filename, transcoded.mimeType, transcoded.size, transcoded.path);
 
-      // Duration automatically: primarily from the transcode (ffmpeg codecData), then
-      // an optional client-side value (bulk uploader reads <audio>.duration),
-      // otherwise NULL (UI then shows '—:—', editable manually in the editor).
-      const clientDur = req.body.duration != null ? parseInt(req.body.duration, 10) : NaN;
-      const finalDuration =
-        (transcoded.durationSec != null && transcoded.durationSec > 0) ? transcoded.durationSec
-        : (Number.isFinite(clientDur) && clientDur > 0) ? clientDur
-        : null;
-
-      console.log('[admin-audio] inserting audio_tracks row (duration=' + finalDuration + ')');
+      console.log('[admin-audio] inserting audio_tracks row');
       db.prepare(`
-        INSERT INTO audio_tracks (id, site_id, title, artist, album, duration, cover_url, credit, license, link_spotify, link_youtube, link_soundcloud, media_id, position)
-        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(
+        INSERT INTO audio_tracks (id, site_id, title, artist, album, cover_url, media_id, position)
+        VALUES (?, ?, ?, ?, ?, ?, ?, COALESCE(
           (SELECT MAX(position) + 1 FROM audio_tracks WHERE site_id = ?),
           0
@@ -252,8 +197,5 @@
         trackId, site.id,
         finalTitle, finalArtist, finalAlbum,
-        finalDuration,
         coverUrl,
-        finalCredit, finalLicense,
-        finalLinkSpotify, finalLinkYoutube, finalLinkSoundcloud,
         mediaId, site.id
       );
@@ -277,17 +219,4 @@
 });
 
-// Download-for-email per track on/off (premium #2). No-JS toggle from the
-// audio admin list → flip + back.
-router.post('/:id/downloadable', requireGod, (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.status(404).send('Site required');
-  const row = db.prepare('SELECT downloadable FROM audio_tracks WHERE id = ? AND site_id = ?').get(req.params.id, site.id);
-  if (row) {
-    db.prepare('UPDATE audio_tracks SET downloadable = ? WHERE id = ? AND site_id = ?')
-      .run(row.downloadable ? 0 : 1, req.params.id, site.id);
-  }
-  res.redirect('/admin/audio');
-});
-
 router.post('/:id/delete', requireGod, (req, res) => {
   const site = res.locals.site;
@@ -301,10 +230,4 @@
 
   if (!track) return res.redirect('/admin/audio?error=Not+found');
-
-  // Zeg de fediverse dat de track weg is, VOOR de rij verdwijnt -- zelfde
-  // volgorde en zelfde reden als bij een post (posts.js). Zonder dit blijft
-  // elke server die hem indexeerde ernaar wijzen terwijl het object 404 geeft;
-  // op de hub stond daardoor op 21-8 een track met een dode link.
-  ActivityPubService.deliverTrackDelete(site, track.track_id).catch(() => { /* best-effort */ });
 
   db.prepare('DELETE FROM audio_tracks WHERE id = ?').run(track.track_id);
@@ -378,11 +301,5 @@
     if (o.media_id) db.prepare('DELETE FROM media WHERE id = ?').run(o.media_id);
   });
-  for (const o of orphans) {
-    // Ook hier aankondigen. Een wees is voor ONS een track zonder bestand, maar
-    // voor de buitenwereld was het een gewoon Audio-object dat zij hebben
-    // opgeslagen; stil weggooien laat hun kopie staan.
-    ActivityPubService.deliverTrackDelete(site, o.track_id).catch(() => { /* best-effort */ });
-    deleteOne(o);
-  }
+  for (const o of orphans) deleteOne(o);
 
   res.json({ ok: true, deleted: orphans.length });
@@ -408,22 +325,4 @@
 
 /** GET /admin/audio/api/:id — single track with all metadata */
-// Create a track WITHOUT an audio file (title + open-in links only). Appears
-// in albums/playlists in the list, with open-in icons but no play button.
-router.post('/create-link', requireGod, express.json(), (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.status(404).json({ error: 'Site required' });
-  const trackId = uuid();
-  const title = ((req.body && req.body.title) || 'Nieuwe track').toString().trim().slice(0, 200) || 'Nieuwe track';
-  try {
-    db.prepare(`
-      INSERT INTO audio_tracks (id, site_id, title, media_id, position)
-      VALUES (?, ?, ?, NULL, COALESCE((SELECT MAX(position) + 1 FROM audio_tracks WHERE site_id = ?), 0))
-    `).run(trackId, site.id, title, site.id);
-  } catch (e) {
-    return res.status(500).json({ error: e.message });
-  }
-  res.json({ ok: true, id: trackId });
-});
-
 router.get('/api/:id', requireGod, (req, res) => {
   const site = res.locals.site;
@@ -431,5 +330,4 @@
   const t = db.prepare(`
     SELECT t.id, t.title, t.artist, t.album, t.duration, t.cover_url,
-           t.credit, t.license, t.link_spotify, t.link_youtube, t.link_soundcloud,
            t.position, t.created_at, m.filename
     FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
@@ -437,6 +335,6 @@
   `).get(req.params.id, site.id);
   if (!t) return res.status(404).json({ error: 'Track niet gevonden' });
-  // Stream URL so the modal can render an inline preview player.
-  const stream_url = t.filename ? audioUrl(t.filename) : null;
+  // Sign the stream URL so the modal can render an inline preview player.
+  const stream_url = t.filename ? signUrl(t.filename).url : null;
   res.json({ ok: true, track: { ...t, stream_url } });
 });
@@ -449,5 +347,5 @@
  * fallback keeps working.
  */
-router.post('/api/:id', requireGod, express.json(), async (req, res) => {
+router.post('/api/:id', requireGod, express.json(), (req, res) => {
   const site = res.locals.site;
   if (!site) return res.status(404).json({ error: 'Site required' });
@@ -491,23 +389,4 @@
   }
 
-  if (Object.prototype.hasOwnProperty.call(body, 'downloadable')) {
-    fields.push('downloadable = ?'); values.push(body.downloadable ? 1 : 0);
-  }
-  if (Object.prototype.hasOwnProperty.call(body, 'credit')) {
-    fields.push('credit = ?'); values.push(String(body.credit || '').trim() || null);
-  }
-  if (Object.prototype.hasOwnProperty.call(body, 'license')) {
-    fields.push('license = ?'); values.push(String(body.license || '').trim() || null);
-  }
-  if (Object.prototype.hasOwnProperty.call(body, 'link_spotify')) {
-    fields.push('link_spotify = ?'); values.push(platformLink(body.link_spotify, LINK_DOMAINS.spotify));
-  }
-  if (Object.prototype.hasOwnProperty.call(body, 'link_youtube')) {
-    fields.push('link_youtube = ?'); values.push(platformLink(body.link_youtube, LINK_DOMAINS.youtube));
-  }
-  if (Object.prototype.hasOwnProperty.call(body, 'link_soundcloud')) {
-    fields.push('link_soundcloud = ?'); values.push(platformLink(body.link_soundcloud, LINK_DOMAINS.soundcloud));
-  }
-
   if (fields.length === 0) {
     return res.status(400).json({ error: 'Niks om te updaten' });
@@ -521,29 +400,10 @@
   }
 
-  // Fresh row + (if tag fields changed) retag the mp3, so that the owner/
-  // licence is also IN the file (ID3) and travels with it on download.
+  // Return fresh row so the caller can update its UI without reloading
   const fresh = db.prepare(`
-    SELECT t.id, t.title, t.artist, t.album, t.duration, t.cover_url, t.credit, t.license, m.storage_path
-    FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
-    WHERE t.id = ? AND t.site_id = ?
+    SELECT id, title, artist, album, duration, cover_url
+    FROM audio_tracks WHERE id = ? AND site_id = ?
   `).get(req.params.id, site.id);
-
-  const tagsChanged = ['title', 'artist', 'album', 'credit', 'license']
-    .some((f) => Object.prototype.hasOwnProperty.call(body, f));
-  if (fresh && fresh.storage_path && tagsChanged) {
-    try {
-      await retagMp3({ filePath: fresh.storage_path, tags: {
-        title: fresh.title || undefined,
-        artist: fresh.artist || undefined,
-        album: fresh.album || undefined,
-        copyright: fresh.credit || undefined,
-        comment: fresh.license || undefined,
-      } });
-    } catch (e) {
-      console.warn('[admin-audio] ID3 retag failed (DB was still updated):', e.message);
-    }
-  }
-  const { storage_path, ...trackOut } = fresh || {};
-  res.json({ ok: true, track: trackOut });
+  res.json({ ok: true, track: fresh });
 });
 
@@ -574,5 +434,5 @@
     }
 
-    const newUrl = `/media/audio-covers/${toWebp(file)}`;
+    const newUrl = `/media/audio-covers/${file.filename}`;
     try {
       db.prepare('UPDATE audio_tracks SET cover_url = ? WHERE id = ? AND site_id = ?')
@@ -596,53 +456,3 @@
 });
 
-// Replace the audio FILE of an existing track (keeps all metadata + the track id, so any
-// [[track:id]] in posts keeps pointing here). Transcodes the new upload to a uniform mp3,
-// swaps the track's media_id + duration, and deletes the old media file/row.
-router.post('/api/:id/replace-audio', requireGod, (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.status(404).json({ ok: false, error: 'Site required' });
-  const track = db.prepare('SELECT id, media_id FROM audio_tracks WHERE id = ? AND site_id = ?').get(req.params.id, site.id);
-  if (!track) return res.status(404).json({ ok: false, error: 'Track niet gevonden' });
-
-  upload.single('audio')(req, res, async (err) => {
-    if (err) return res.status(400).json({ ok: false, error: err.message });
-    const file = req.file;
-    if (!file) return res.status(400).json({ ok: false, error: 'Geen bestand' });
-    const ext = path.extname(file.originalname).toLowerCase();
-    const limit = audioByteLimitFor(ext);
-    if (file.size > limit) {
-      try { fs.unlinkSync(file.path); } catch {}
-      return res.status(413).json({ ok: false, error: `Te groot (max ${Math.round(limit / 1024 / 1024)}MB voor ${ext || 'dit type'})` });
-    }
-
-    let transcoded;
-    try {
-      transcoded = await transcodeToMp3({
-        inputPath: file.path, outputDir: AUDIO_DIR,
-        outputBaseName: path.basename(file.filename, path.extname(file.filename)), tags: {},
-      });
-    } catch (e) {
-      try { fs.unlinkSync(file.path); } catch {}
-      return res.status(500).json({ ok: false, error: 'Conversie mislukt: ' + e.message });
-    }
-
-    const newMediaId = uuid();
-    try {
-      db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
-        .run(newMediaId, site.id, transcoded.filename, transcoded.mimeType, transcoded.size, transcoded.path);
-      db.prepare('UPDATE audio_tracks SET media_id = ? WHERE id = ? AND site_id = ?').run(newMediaId, track.id, site.id);
-      const dur = (transcoded.durationSec != null && transcoded.durationSec > 0) ? transcoded.durationSec : null;
-      if (dur) db.prepare('UPDATE audio_tracks SET duration = ? WHERE id = ?').run(dur, track.id);
-      // Remove the OLD media (file + row), best-effort.
-      if (track.media_id && track.media_id !== newMediaId) {
-        try { const old = db.prepare('SELECT storage_path FROM media WHERE id = ?').get(track.media_id); if (old && old.storage_path) fs.unlinkSync(old.storage_path); } catch {}
-        try { db.prepare('DELETE FROM media WHERE id = ?').run(track.media_id); } catch {}
-      }
-      return res.json({ ok: true, stream_url: audioUrl(transcoded.filename), duration: dur });
-    } catch (e) {
-      return res.status(500).json({ ok: false, error: e.message });
-    }
-  });
-});
-
 export default router;
Index: src/routes/admin-comments.js
===================================================================
--- src/routes/admin-comments.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
+++ src/routes/admin-comments.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -0,0 +1,80 @@
+/**
+ * Admin: Comment moderation queue — Phase E.
+ *
+ * GET  /admin/comments              -> list pending + recent (god-only)
+ * POST /admin/comments/:id/approve  -> set status = 'approved'
+ * POST /admin/comments/:id/reject   -> set status = 'rejected' (keeps the row
+ *                                      so we have a paper trail; admin can
+ *                                      hard-delete via the post page).
+ *
+ * Scope: shows comments for the resolved site only (the one matched by
+ * /sites/:slug or default). Future: filter by status / search.
+ */
+
+import express from 'express';
+import db from '../config/database.js';
+import { renderPage } from '../middleware/render.js';
+import { requireGod } from '../middleware/auth.js';
+
+const router = express.Router();
+
+router.get('/', requireGod, (req, res) => {
+  const site = res.locals.site;
+  if (!site) return res.status(404).send('No site');
+
+  const pending = db.prepare(`
+    SELECT c.id, c.content, c.created_at, c.parent_comment_id,
+           u.username AS author_username,
+           p.slug AS post_slug, p.title AS post_title
+    FROM comments c
+    JOIN users u ON u.id = c.author_id
+    JOIN posts p ON p.id = c.post_id
+    WHERE p.site_id = ? AND c.status = 'pending'
+    ORDER BY c.created_at ASC
+    LIMIT 200
+  `).all(site.id);
+
+  const recent = db.prepare(`
+    SELECT c.id, c.content, c.created_at, c.status,
+           u.username AS author_username,
+           p.slug AS post_slug, p.title AS post_title
+    FROM comments c
+    JOIN users u ON u.id = c.author_id
+    JOIN posts p ON p.id = c.post_id
+    WHERE p.site_id = ? AND c.status IN ('approved', 'rejected')
+    ORDER BY c.created_at DESC
+    LIMIT 30
+  `).all(site.id);
+
+  renderPage(req, res, 'pages/admin-comments', {
+    pageTitle: 'Comment moderation',
+    bodyClass: 'on-admin',
+    pending,
+    recent,
+    moderationMode: site.comments_moderation_mode || 'trust',
+    success: req.query.success || null,
+    error: req.query.error || null,
+  });
+});
+
+function setStatus(req, res, status) {
+  const site = res.locals.site;
+  if (!site) return res.status(404).send('No site');
+
+  const row = db.prepare(`
+    SELECT c.id FROM comments c JOIN posts p ON p.id = c.post_id
+    WHERE c.id = ? AND p.site_id = ?
+  `).get(req.params.id, site.id);
+
+  if (!row) return res.redirect('/admin/comments?error=Not+found');
+
+  db.prepare(
+    'UPDATE comments SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
+  ).run(status, req.params.id);
+  res.redirect('/admin/comments?success=' + encodeURIComponent('Comment ' + status));
+}
+
+router.post('/:id/approve', requireGod, (req, res) => setStatus(req, res, 'approved'));
+router.post('/:id/reject',  requireGod, (req, res) => setStatus(req, res, 'rejected'));
+
+export default router;
Index: src/routes/admin-epk.js
===================================================================
--- src/routes/admin-epk.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,63 +1,0 @@
-/**
- * Admin: Edit press kit (EPK) — per-site bio + press contact.
- *
- * GET  /admin/epk   -> form with current bio + contact
- * POST /admin/epk   -> save (app_settings: epk_bio_<siteId> / epk_contact_<siteId>)
- *
- * The press kit page itself (/pers) reads these values; tracks + recent posts come
- * automatically. Press kit is premium + solo (see routes/epk.js).
- */
-
-import express from 'express';
-import db from '../config/database.js';
-import { renderPage } from '../middleware/render.js';
-import { requireGod } from '../middleware/auth.js';
-import { getSetting, setSetting } from '../services/SettingsService.js';
-import { premiumUnlocked } from '../services/PatreonService.js';
-import { t, resolveLang } from '../services/i18n.js';
-
-const router = express.Router();
-
-const MAX_EPK_TRACKS = 5;
-
-router.get('/', requireGod, (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.status(404).send('Site required');
-  if (!premiumUnlocked()) {
-    return res.status(403).send('Press kit is a premium feature.');
-  }
-  const allTracks = db.prepare(
-    `SELECT id, title, artist FROM audio_tracks WHERE site_id = ? ORDER BY position ASC, created_at ASC`
-  ).all(site.id);
-  let chosen = [];
-  try { const r = JSON.parse(getSetting('epk_tracks_' + site.id, '') || '[]'); if (Array.isArray(r)) chosen = r; } catch (e) {}
-  renderPage(req, res, 'pages/admin-epk', {
-    pageJs: 'admin-epk',
-    pageTitleKey: 'admin.t_epk',
-    bodyClass: 'on-admin',
-    site,
-    epkBio: getSetting('epk_bio_' + site.id, '') || '',
-    epkContact: getSetting('epk_contact_' + site.id, '') || '',
-    allTracks,
-    chosenTracks: chosen,
-    maxEpkTracks: MAX_EPK_TRACKS,
-    success: req.query.success || null,
-  });
-});
-
-router.post('/', requireGod, (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.status(404).send('Site required');
-  setSetting('epk_bio_' + site.id, (req.body.epk_bio || '').toString().slice(0, 1000).trim());
-  setSetting('epk_contact_' + site.id, (req.body.epk_contact || '').toString().slice(0, 300).trim());
-  // Chosen tracks: only ids belonging to THIS site, max 5, in the supplied order.
-  let ids = req.body.epk_tracks;
-  if (!Array.isArray(ids)) ids = ids ? [ids] : [];
-  const valid = new Set(db.prepare('SELECT id FROM audio_tracks WHERE site_id = ?').all(site.id).map((r) => r.id));
-  ids = ids.map(String).filter((id) => valid.has(id)).slice(0, MAX_EPK_TRACKS);
-  setSetting('epk_tracks_' + site.id, JSON.stringify(ids));
-  const lang = resolveLang(req, { defaultLang: getSetting('default_lang') });
-  res.redirect('/admin/epk?success=' + encodeURIComponent(t(lang, 'aepk.saved')));
-});
-
-export default router;
Index: src/routes/admin-listeners.js
===================================================================
--- src/routes/admin-listeners.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,31 +1,0 @@
-/**
- * GET /admin/listeners — wie je BIBLIOTHEEK volgt (shaer-0nh).
- *
- * Een eigen tab in Mediabeheer, want dit is een eigen soort relatie: deze
- * accounts hangen aan /ap/users/<slug>/library en niet aan de actor. Ze krijgen
- * de muziek en met opzet niet de gewone posts -- wie zich op een platenkast
- * abonneert heeft niet om de Krant gevraagd.
- *
- * Dat verschil hoort ZICHTBAAR te zijn. Stonden ze tussen de gewone volgers,
- * dan zou niemand later begrijpen waarom ze andere dingen krijgen.
- */
-import express from 'express';
-import { requireGod } from '../middleware/auth.js';
-import { renderPage } from '../middleware/render.js';
-import { audioEnabled } from '../config/features.js';
-import { luisteraars } from '../services/music/index.js';
-
-const router = express.Router();
-
-router.get('/', requireGod, (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.status(404).send('No site');
-  renderPage(req, res, 'pages/admin-listeners', {
-    pageTitleKey: 'admin.b_listeners',
-    bodyClass: 'on-admin',
-    audioOn: audioEnabled(),
-    luisteraars: luisteraars.lijst(site.slug),
-  });
-});
-
-export default router;
Index: src/routes/admin-media.js
===================================================================
--- src/routes/admin-media.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,176 +1,0 @@
-/**
- * admin-media.js — Beheer → Media (image library + cleanup).
- *
- * Lists the uploaded images under storage/media/post-images, shows where each is used, and lets the
- * owner copy a URL or delete unused files. An animated cover's WebP, its loop MP4 (<base>-v.mp4) and
- * poster (<base>-v.jpg) are treated as one item; deleting removes the trio. The Audio half of "Media"
- * stays at /admin/audio (linked as a tab) — this page is the new image side.
- */
-import express from 'express';
-import path from 'path';
-import fs from 'fs';
-import db from '../config/database.js';
-import { renderPage } from '../middleware/render.js';
-import { requireGod } from '../middleware/auth.js';
-import { audioEnabled } from '../config/features.js';
-import { mediaDir } from '../config/paths.js';
-
-const POST_IMAGES_DIR = mediaDir('POST_IMAGES_PATH', 'post-images');
-
-const router = express.Router();
-
-const IMG_EXT = /\.(jpe?g|png|webp|gif|avif)$/i;
-const VIDEO_EXT = /\.(mp4|webm|m4v|mov)$/i;
-// C2S uploads (Shaer's composer and the help buoy) land here; the videos among
-// them are what the Video tab shows.
-const REPLY_MEDIA_DIR = mediaDir('REPLY_MEDIA_PATH', 'reply-media');
-const isSibling = (f) => /-v\.(mp4|jpg)$/i.test(f); // an animated cover's video/poster sibling
-
-// Basename of a /media/post-images/<file> URL (or null).
-function baseOf(url) {
-  const m = String(url || '').match(/\/media\/post-images\/([^/?#"'\s)]+)/);
-  return m ? m[1] : null;
-}
-
-// Map filename -> Set(postId) of posts that reference it (as cover or inline image).
-function usageMap(siteId) {
-  const posts = db.prepare('SELECT id, content, cover_image_url, cover_video_url FROM posts WHERE site_id = ?').all(siteId);
-  const map = new Map();
-  const add = (fn, id) => { if (!fn) return; if (!map.has(fn)) map.set(fn, new Set()); map.get(fn).add(id); };
-  for (const p of posts) {
-    add(baseOf(p.cover_image_url), p.id);
-    add(baseOf(p.cover_video_url), p.id);
-    for (const m of String(p.content || '').matchAll(/\/media\/post-images\/([^/?#"'\s)]+)/g)) add(m[1], p.id);
-  }
-  return map;
-}
-
-function statSize(name) { try { return fs.statSync(path.join(POST_IMAGES_DIR, name)).size; } catch { return 0; } }
-function statMtime(name) { try { return fs.statSync(path.join(POST_IMAGES_DIR, name)).mtimeMs; } catch { return 0; } }
-
-// All non-sibling images, each with its loop-MP4 sibling + how many posts use it. Shared by the
-// list view and the cleanup route so the readdir/filter/usage logic lives in one place.
-function imageEntries(siteId) {
-  const used = usageMap(siteId);
-  let all = [];
-  try { all = fs.readdirSync(POST_IMAGES_DIR).filter(f => !f.startsWith('.')); } catch { /* dir may not exist yet */ }
-  const present = new Set(all);
-  return all
-    .filter(f => IMG_EXT.test(f) && !isSibling(f))
-    .map(f => {
-      const stem = f.replace(/\.[^.]+$/, '');
-      const mp4 = `${stem}-v.mp4`;
-      const hasVideo = present.has(mp4);
-      const ids = new Set([...(used.get(f) || []), ...(hasVideo ? (used.get(mp4) || []) : [])]);
-      return { file: f, stem, mp4, hasVideo, usedCount: ids.size };
-    });
-}
-
-router.get('/', requireGod, (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.status(404).send('Site required');
-  const items = imageEntries(site.id)
-    .map(e => ({
-      file: e.file,
-      url: `/media/post-images/${e.file}`,
-      kb: Math.round((statSize(e.file) + (e.hasVideo ? statSize(e.mp4) : 0)) / 1024),
-      hasVideo: e.hasVideo,
-      usedCount: e.usedCount,
-      _mtime: statMtime(e.file),
-    }))
-    .sort((a, b) => b._mtime - a._mtime); // newest first
-  renderPage(req, res, 'pages/admin-media', {
-    pageJs: 'admin-media',
-    pageTitleKey: 'admin.t_media',
-    bodyClass: 'on-admin',
-    items,
-    unusedCount: items.filter(i => !i.usedCount).length,
-    audioOn: audioEnabled(),
-    success: req.query.success || null,
-  });
-});
-
-// Delete one image + its loop-MP4 / poster siblings. Basename-only + within-dir → no traversal.
-// ── The Video tab (Robins opdracht, 30-7) ─────────────────────────────────
-// Videos live in reply-media (C2S uploads: Shaer's composer, the help buoy).
-// Usage is a content/attachment reference from a post, exactly like images.
-
-function videoEntries(siteId) {
-  const posts = db.prepare('SELECT id, content, c2s_attachments FROM posts WHERE site_id = ?').all(siteId);
-  const used = new Map();
-  const add = (fn, id) => { if (!fn) return; if (!used.has(fn)) used.set(fn, new Set()); used.get(fn).add(id); };
-  for (const p of posts) {
-    for (const m of String(p.content || '').matchAll(/\/media\/reply-media\/([^/?#"'\s)]+)/g)) add(m[1], p.id);
-    try { for (const a of JSON.parse(p.c2s_attachments || '[]')) { const m = String(a.url || '').match(/\/media\/reply-media\/([^/?#"'\s)]+)/); if (m) add(m[1], p.id); } } catch { /* malformed never blocks the list */ }
-  }
-  let all = [];
-  try { all = fs.readdirSync(REPLY_MEDIA_DIR).filter(f => !f.startsWith('.')); } catch { /* dir may not exist yet */ }
-  const vstat = (name, key) => { try { const st = fs.statSync(path.join(REPLY_MEDIA_DIR, name)); return key === 'size' ? st.size : st.mtimeMs; } catch { return 0; } };
-  return all
-    .filter(f => VIDEO_EXT.test(f))
-    .map(f => ({
-      file: f,
-      url: `/media/reply-media/${f}`,
-      kb: Math.round(vstat(f, 'size') / 1024),
-      usedCount: (used.get(f) || new Set()).size,
-      _mtime: vstat(f, 'mtime'),
-    }))
-    .sort((a, b) => b._mtime - a._mtime);
-}
-
-router.get('/videos', requireGod, (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.status(404).send('Site required');
-  renderPage(req, res, 'pages/admin-videos', {
-    pageJs: 'admin-videos',
-    pageTitleKey: 'admin.t_media',
-    bodyClass: 'on-admin',
-    items: videoEntries(site.id),
-    audioOn: audioEnabled(),
-    success: req.query.success || null,
-  });
-});
-
-// Delete one video. Basename-only + within-dir, and only when no post uses it:
-// the same guardrails the image delete has.
-router.post('/videos/delete', requireGod, (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.status(404).json({ error: 'site' });
-  const file = path.basename(String(req.body?.file || ''));
-  if (!file || !VIDEO_EXT.test(file)) return res.status(400).json({ error: 'bad_file' });
-  const entry = videoEntries(site.id).find(e => e.file === file);
-  if (!entry) return res.status(404).json({ error: 'not_found' });
-  if (entry.usedCount) return res.status(409).json({ error: 'in_use' });
-  try { fs.unlinkSync(path.join(REPLY_MEDIA_DIR, file)); } catch { /* already gone is gone */ }
-  res.json({ ok: true });
-});
-
-router.post('/delete', requireGod, (req, res) => {
-  if (!res.locals.site) return res.status(404).json({ ok: false, error: 'Site required' });
-  const f = String(req.body.file || '');
-  if (!f || path.basename(f) !== f || !IMG_EXT.test(f)) return res.status(400).json({ ok: false, error: 'Bad file' });
-  const stem = f.replace(/\.[^.]+$/, '');
-  let removed = 0;
-  for (const name of [f, `${stem}-v.mp4`, `${stem}-v.jpg`]) {
-    const full = path.join(POST_IMAGES_DIR, name);
-    if (path.dirname(full) !== POST_IMAGES_DIR) continue;
-    try { fs.unlinkSync(full); removed++; } catch { /* missing sibling */ }
-  }
-  res.json({ ok: true, removed });
-});
-
-// Delete every unused image (orphan) + its siblings.
-router.post('/cleanup', requireGod, (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.status(404).json({ ok: false, error: 'Site required' });
-  let removed = 0;
-  for (const e of imageEntries(site.id)) {
-    if (e.usedCount) continue; // still in use
-    for (const name of [e.file, e.mp4, `${e.stem}-v.jpg`]) {
-      try { fs.unlinkSync(path.join(POST_IMAGES_DIR, name)); removed++; } catch { /* */ }
-    }
-  }
-  res.json({ ok: true, removed });
-});
-
-export default router;
Index: src/routes/admin-migrate.js
===================================================================
--- src/routes/admin-migrate.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,275 +1,0 @@
-/**
- * admin-migrate.js — Beheer → Migreren: je posts en media naar een andere Klonkt.
- *
- * Dezelfde machinerie als scripts/export-archive.mjs en import-archive.mjs, maar via
- * de webinterface, zodat verhuizen geen SSH-toegang meer vraagt. De services doen
- * het werk; deze routes zijn de deur.
- *
- * DRIE KEUZES DIE ER TOE DOEN:
- *
- * 1. IMPORTEREN GAAT ALTIJD EERST DROOG. Je krijgt een verslag te zien en pas
- *    daarna een knop die het echt doet. Een archief inlezen is niet terug te
- *    draaien, en "ik dacht dat ik alleen keek" is de duurste vergissing hier.
- *
- * 2. OVERSCHRIJVEN IS EEN APARTE, GEWAARSCHUWDE KEUZE. importArchive gebruikt
- *    INSERT OR REPLACE, en dat verwijdert ELKE rij die een unieke sleutel schendt:
- *    een post met een ander id maar dezelfde slug gaat dus stil mee (zie shaer-snv5).
- *    Standaard slaat hij bestaande posts over, en dat blijft ook de standaard hier.
- *
- * 3. HET ARCHIEF WORDT IN GEHEUGEN GEBOUWD. buildArchive levert een Map van
- *    Buffers; bij veel media is dat een forse allocatie. Daarom een harde grens en
- *    een eerlijke melding in plaats van een proces dat omvalt. Streamen is
- *    shaer-190t en hoort daar thuis, niet hier.
- */
-import express from 'express';
-import multer from 'multer';
-import fs from 'fs';
-import path from 'path';
-import { renderPage } from '../middleware/render.js';
-import { requireGod } from '../middleware/auth.js';
-import ActivityPubService from '../services/ActivityPubService.js';
-import { safeFetch, signedGetJson, signedGetHeaders, noteId, noteVisibility } from '../services/ActivityPubService.js';
-import HtmlSanitizerService from '../services/HtmlSanitizerService.js';
-import { MEDIA_ROOT, AUDIO_ROOT } from '../config/paths.js';
-import * as Migration from '../services/MigrationService.js';
-import { buildArchive, zipArchive } from '../services/ArchiveExportService.js';
-import { readArchiveZip, importArchive } from '../services/ArchiveImportService.js';
-import { parseApAliases } from './admin-sites.js';
-import db from '../config/database.js';
-
-const router = express.Router();
-
-// Ruim genoeg voor een gewone site met media, klein genoeg om een instance niet om
-// te laten vallen op een archief dat iemand toevallig aanlevert.
-const MAX_UPLOAD = 512 * 1024 * 1024;
-const MAX_EXPORT = 512 * 1024 * 1024;
-
-const upload = multer({
-  storage: multer.memoryStorage(),
-  limits: { fileSize: MAX_UPLOAD, files: 1 },
-}).single('archief');
-
-const mb = (n) => (n < 1024 * 1024 ? `${Math.max(1, Math.round(n / 1024))} kB` : `${(n / (1024 * 1024)).toFixed(1)} MB`);
-
-/** Wat zou er in een export zitten? Droog gebouwd, dus zonder zip. */
-function tellen(site) {
-  if (!site) return { telling: null, fout: null };
-  try {
-    const r = buildArchive(site.slug);
-    let bytes = 0;
-    for (const buf of r.files.values()) bytes += buf.length;
-    // ontbrekend telt alleen de MEDIA-verwijzingen; audioMissing komt uit
-    // buildArchive zelf. Ze door elkaar husselen was precies hoe "39
-    // mediabestanden, 14 ontbrekend" een bibliotheek van 140 nummers kon
-    // verzwijgen.
-    const mediaWeg = r.missing.filter((m) => !m.track).length;
-    return {
-      telling: {
-        ...r.counts, bytes, groot: bytes > MAX_EXPORT,
-        ontbrekend: mediaWeg, audioMissing: r.counts.audioMissing || 0,
-      },
-      fout: null,
-    };
-  } catch (e) { return { telling: null, fout: e && e.message }; }
-}
-
-/**
- * De gekoppelde accounts als tekst, een per regel.
- *
- * Als HANDLE, niet als de opgeslagen URL. Je typt @jij@mastodon.social, wij
- * slaan de actor-URL op omdat de rest van het protocol daarop draait, en dan
- * kreeg je een adres terug dat je nooit hebt ingetypt en niet herkent. Wat je
- * hier ziet hoort te lijken op wat je gaf.
- *
- * Alleen als er echt een handle uit te halen valt; anders de URL, want een
- * verkeerde handle is erger dan een lelijke URL. parseApAliases leest beide
- * vormen, dus opslaan blijft werken wat er ook in het veld staat.
- */
-function aliasTekst(site) {
-  try {
-    const lijst = JSON.parse((site && site.ap_aliases) || '[]') || [];
-    return lijst.map((u) => {
-      const h = ActivityPubService.deriveHandle(u);
-      return /^@[^@\s]+@[^@\s]+$/.test(h) ? h : u;
-    }).join('\n');
-  } catch { return ''; }
-}
-
-/**
- * Waar sta je in de verhuizing?
- *
- * Deze pagina draait op BEIDE instanties en elke stap hoort maar op een van de
- * twee. Zonder dat onderscheid leest de lijst als onzin op de helft van de
- * schermen. Vandaar: afleiden wat we kunnen zien, en verder eerlijk zeggen
- * waar iets thuishoort in plaats van het te raden.
- */
-function stappen(site, mig) {
-  const alias = aliasTekst(site).trim();
-  return {
-    // moved_to gezet betekent: DIT is de instantie die vertrokken is.
-    isOud: !!(site && site.moved_to),
-    geclaimd: !!alias,
-    // Een Move in onze moves-collectie betekent dat de bron hierheen verhuisd is.
-    aangekondigd: !!(site && site.moved_to) || !!(mig && mig.moves > 0),
-    opgehaald: !!(mig && mig.total > 0),
-  };
-}
-
-/**
- * Waar zouden we vandaan kunnen halen? De alias die we zelf claimen (FEP-7628
- * alsoKnownAs). Dat is niet toevallig hetzelfde veld als waar de ingest op
- * controleert: het is de helft van de afspraak die je hier al gezet hebt.
- */
-function bronKandidaat(site) {
-  try {
-    const aka = JSON.parse((site && site.ap_aliases) || '[]');
-    return Array.isArray(aka) ? aka.find((u) => typeof u === 'string' && /^https?:\/\//i.test(u)) || null : null;
-  } catch { return null; }
-}
-
-/** De pagina. Toont wat er in een export zou zitten, zonder hem te bouwen. */
-router.get('/', requireGod, (req, res) => {
-  // Droog bouwen om te tellen. Dat kost hetzelfde geheugen als een echte export,
-  // dus hier meteen de grens bewaken in plaats van pas bij de download.
-  const site = res.locals.site;
-  const { telling, fout } = tellen(site);
-  const _mig = site ? Migration.migrationStatus(site.slug) : null;
-  renderPage(req, res, 'pages/admin-migrate', {
-    pageTitle: 'Migreren', bodyClass: 'on-special',
-    telling, fout, mb,
-    verslag: null,
-    bron: bronKandidaat(site), aliassen: aliasTekst(site), movedTo: (site && site.moved_to) || null,
-    mig: _mig, stap: stappen(site, _mig),
-    haalVerslag: null,
-    success: req.query.success || null, error: req.query.error || fout || null,
-  });
-});
-
-/**
- * FEP-1580: haal de berichten rechtstreeks bij je oude Klonkt op.
- *
- * Geen code, geen token: de autorisatie IS de Move die je al gedaan hebt. De
- * oude instantie geeft ons zijn eigen kijkrechten omdat `moved_to` daar naar
- * ons wijst, en dat veld staat er alleen als wij hem toen al in alsoKnownAs
- * hadden. Beide kanten hebben dus ooit ja gezegd, en dat is precies waarom
- * hier geen tweede vertrouwensmechanisme bij hoeft.
- *
- * De ingest kijkt die afspraak zelf nog een keer na, in beide richtingen. Dit
- * is een deur, geen controle.
- */
-router.post('/pull', requireGod, async (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen site'));
-  const opgegeven = String(req.body && req.body.bron ? req.body.bron : '').trim();
-  let r = null;
-  try {
-    r = await Migration.ingestFromSource(site, {
-      sourceUri: opgegeven || null,
-      deps: {
-        getJson: signedGetJson, safeFetch, mediaRoot: MEDIA_ROOT, audioRoot: AUDIO_ROOT, fs, path, noteId, noteVisibility, signHeaders: signedGetHeaders,
-        sanitize: (h) => HtmlSanitizerService.sanitize(h || ''),
-      },
-    });
-  } catch (e) {
-    r = { error: 'crash', melding: e && e.message };
-  }
-  const { telling } = tellen(site);
-  const _mig2 = Migration.migrationStatus(site.slug);
-  renderPage(req, res, 'pages/admin-migrate', {
-    pageTitle: 'Migreren', bodyClass: 'on-special',
-    telling, fout: null, mb, verslag: null,
-    bron: opgegeven || bronKandidaat(site), aliassen: aliasTekst(site), movedTo: site.moved_to || null,
-    mig: _mig2, stap: stappen(site, _mig2),
-    haalVerslag: r,
-    success: (r && !r.error) ? 'Opgehaald' : null,
-    error: null,
-  });
-});
-
-/**
- * Je oude account claimen (FEP-7628 alsoKnownAs).
- *
- * Stond op de site-bewerkpagina, tussen de kleuren en de feedinstellingen. Maar
- * dit is stap EEN van een verhuizing, en zonder deze claim weigert de oude
- * instantie de Move met `no_backreference`. Het hoort dus hier, boven de knop
- * die hem nodig heeft.
- */
-router.post('/aliases', requireGod, async (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen site'));
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  let lijst;
-  try {
-    lijst = await parseApAliases(req.body.ap_aliases, ActivityPubService.actorId(base, site.slug));
-  } catch (e) {
-    // Welke regel niet deugde, niet alleen DAT er iets niet deugde.
-    return res.redirect('/admin/migrate?error=' + encodeURIComponent(`Onbruikbaar adres: ${e && e.message}`));
-  }
-  db.prepare('UPDATE sites SET ap_aliases = ? WHERE slug = ?').run(JSON.stringify(lijst), site.slug);
-  res.redirect('/admin/migrate?success=' + encodeURIComponent(
-    lijst.length ? `${lijst.length} adres(sen) opgeslagen als jouw vorige account.` : 'Aliassen leeggemaakt.'));
-});
-
-/** Download het archief als zip. */
-router.get('/export', requireGod, (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen site'));
-  try {
-    const r = buildArchive(site.slug);
-    const zip = zipArchive(r.files);
-    if (zip.length > MAX_EXPORT) {
-      return res.redirect('/admin/migrate?error=' + encodeURIComponent(
-        `Het archief is ${mb(zip.length)} en dat is te groot voor de webinterface. Gebruik scripts/export-archive.mjs op de server.`));
-    }
-    const naam = `klonkt-${site.slug}-${new Date().toISOString().slice(0, 10)}.zip`;
-    res.set('Content-Type', 'application/zip');
-    res.set('Content-Disposition', `attachment; filename="${naam}"`);
-    // Privé: dit is je hele site, niets voor een cache onderweg.
-    res.set('Cache-Control', 'private, no-store');
-    res.send(zip);
-  } catch (e) {
-    res.redirect('/admin/migrate?error=' + encodeURIComponent(`Exporteren mislukt: ${e && e.message}`));
-  }
-});
-
-/**
- * Importeren. Zonder `echt=1` is dit een DROOGLOOP: hij leest het archief, meldt
- * wat er zou gebeuren, en raakt niets aan.
- */
-router.post('/import', requireGod, upload, async (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen site'));
-  if (!req.file || !req.file.buffer || !req.file.buffer.length) {
-    return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen bestand ontvangen'));
-  }
-
-  const echt = String(req.body.echt || '') === '1';
-  const overschrijf = String(req.body.overschrijf || '') === '1';
-
-  let verslag = null;
-  let fout = null;
-  try {
-    const files = readArchiveZip(req.file.buffer);
-    verslag = importArchive(files, {
-      slug: site.slug,
-      dryRun: !echt,
-      overwrite: overschrijf,
-    });
-  } catch (e) {
-    fout = e && e.message;
-  }
-
-  // Bij een DROOGLOOP blijven we op de pagina met het verslag, zodat je kunt
-  // besluiten. Bij een echte import ook, maar dan met de uitkomst.
-  renderPage(req, res, 'pages/admin-migrate', {
-    pageTitle: 'Migreren', bodyClass: 'on-special',
-    telling: tellen(site).telling, fout: null, mb,
-    bron: bronKandidaat(site), aliassen: aliasTekst(site), movedTo: site.moved_to || null,
-    mig: Migration.migrationStatus(site.slug), stap: stappen(site, Migration.migrationStatus(site.slug)), haalVerslag: null,
-    verslag: verslag ? { ...verslag, echt, overschrijf, bestand: req.file.originalname, bytes: req.file.buffer.length } : null,
-    success: (echt && verslag && !fout) ? 'Archief geïmporteerd' : null,
-    error: fout ? `Importeren mislukt: ${fout}` : null,
-  });
-});
-
-export default router;
Index: src/routes/admin-newsletter.js
===================================================================
--- src/routes/admin-newsletter.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,99 +1,0 @@
-/**
- * Newsletter — admin side (premium feature #1).
- *
- *   GET  /admin/newsletter        -> compose + subscriber counts + history
- *   POST /admin/newsletter/send   -> send to all CONFIRMED subscribers (SMTP)
- *
- * Premium-gated + site manager. Sending requires configured SMTP; without SMTP
- * sign-ups are still collected (single opt-in), only sending is unavailable.
- */
-
-import express from 'express';
-import db from '../config/database.js';
-import { v4 as uuid } from 'uuid';
-import { renderPage } from '../middleware/render.js';
-import { requireSiteManager } from '../middleware/auth.js';
-import { premiumUnlocked } from '../services/PatreonService.js';
-import { mailerConfigured, sendMail } from '../config/mailer.js';
-import { confirmedFor, counts } from '../services/SubscriberService.js';
-import { t, resolveLang } from '../services/i18n.js';
-import { getSetting } from '../services/SettingsService.js';
-
-const router = express.Router();
-
-function esc(s) {
-  return String(s || '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
-}
-function fullUrl(req, p) {
-  const base = (process.env.PUBLIC_BASE_URL || ('https://' + (req.get('host') || ''))).replace(/\/$/, '');
-  return base + (res_siteUrlBase(req)) + p;
-}
-function res_siteUrlBase(req) {
-  return req.res && req.res.locals ? (req.res.locals.siteUrlBase || '') : '';
-}
-
-function premiumGate(req, res, next) {
-  if (!premiumUnlocked()) {
-    const lang = resolveLang(req, { defaultLang: getSetting('default_lang') });
-    return res.status(403).send(t(lang, 'aset.premium_gate', { feature: t(lang, 'admin.t_newsletter') }));
-  }
-  next();
-}
-
-function renderCompose(req, res, extra = {}) {
-  const site = res.locals.site;
-  const c = counts(site.id);
-  const history = db.prepare(
-    'SELECT subject, sent_at, recipient_count FROM newsletters WHERE site_id = ? ORDER BY sent_at DESC LIMIT 10'
-  ).all(site.id);
-  const subscribeUrl = fullUrl(req, '/nieuwsbrief');
-  renderPage(req, res, 'pages/admin-newsletter', {
-    pageTitleKey: 'admin.t_newsletter',
-    bodyClass: 'on-admin',
-    nlCounts: c,
-    nlHistory: history,
-    nlSubscribeUrl: subscribeUrl,
-    nlSmtp: mailerConfigured(),
-    ...extra,
-  });
-}
-
-router.get('/', requireSiteManager, premiumGate, (req, res) => {
-  if (!res.locals.site) return res.status(404).send('Geen site.');
-  renderCompose(req, res);
-});
-
-router.post('/send', requireSiteManager, premiumGate, async (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.status(404).send('Geen site.');
-  if (!mailerConfigured()) return renderCompose(req, res, { nlMsg: 'SMTP is niet ingesteld — versturen kan nog niet.', nlMsgKind: 'bad' });
-
-  const subject = (req.body.subject || '').trim();
-  const body = (req.body.body || '').trim();
-  if (!subject || !body) return renderCompose(req, res, { nlMsg: 'Onderwerp en bericht zijn verplicht.', nlMsgKind: 'bad', nlSubject: subject, nlBody: body });
-
-  const subs = confirmedFor(site.id);
-  const bodyHtml = esc(body).replace(/\n/g, '<br>');
-  let sent = 0;
-  for (const s of subs) {
-    const unsub = fullUrl(req, '/nieuwsbrief/uitschrijven/' + s.token);
-    try {
-      await sendMail({
-        to: s.email,
-        subject,
-        text: body + '\n\n—\nUitschrijven: ' + unsub,
-        html: '<div>' + bodyHtml + '</div>' +
-              '<hr style="margin-top:24px;border:none;border-top:1px solid #ddd">' +
-              '<p style="color:#888;font-size:12px">Je ontvangt dit omdat je je aanmeldde voor de nieuwsbrief van ' +
-              esc(site.title) + '. <a href="' + unsub + '">Uitschrijven</a>.</p>',
-      });
-      sent++;
-    } catch (e) { /* skip this recipient, continue */ }
-  }
-  db.prepare('INSERT INTO newsletters (id, site_id, subject, body, recipient_count) VALUES (?,?,?,?,?)')
-    .run(uuid(), site.id, subject, body, sent);
-
-  renderCompose(req, res, { nlMsg: 'Verstuurd naar ' + sent + ' van ' + subs.length + ' abonnee(s).', nlMsgKind: 'ok' });
-});
-
-export default router;
Index: src/routes/admin-paid.js
===================================================================
--- src/routes/admin-paid.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,80 +1,0 @@
-/**
- * Admin: Paid posts (premium module, god-only). Slice 1 of klonkt-demo-aki.
- *
- * GET  /admin/paid          -> the owner's Patreon config form + status
- * POST /admin/paid          -> save config (secret/token stored encrypted)
- * POST /admin/paid/disconnect -> forget the config
- *
- * Premium-gated via premiumUnlocked(), like stats/downloads. This is the site
- * owner's OWN Patreon campaign, separate from Klonkt Premium's license flow.
- */
-import express from 'express';
-import { renderPage } from '../middleware/render.js';
-import { requireGod } from '../middleware/auth.js';
-import { premiumUnlocked } from '../services/PatreonService.js';
-import { cryptoBoxReady } from '../services/CryptoBox.js';
-import PaidPatreon from '../services/PaidPatreonService.js';
-
-const router = express.Router();
-
-// The redirect URI the owner MUST whitelist in their Patreon client. Must match
-// exactly what paid.js sends, or Patreon shows its own error page (which we
-// cannot skin) instead of returning the visitor to us.
-const redirectUri = (req) =>
-  (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '') + '/paid/callback';
-
-function gate(req, res) {
-  if (!premiumUnlocked()) {
-    res.status(403).send('Betaalde posts is een premium-functie: koppel Patreon in Beheer, Instellingen.');
-    return false;
-  }
-  if (!res.locals.site) { res.status(400).send('Geen site.'); return false; }
-  return true;
-}
-
-router.get('/', requireGod, (req, res) => {
-  if (!gate(req, res)) return;
-  renderPage(req, res, 'pages/admin-paid', {
-    pageJs: 'admin-paid',
-    pageTitleKey: 'apaid.t',
-    bodyClass: 'on-admin',
-    status: PaidPatreon.ownerStatus(res.locals.site.id),
-    secretReady: cryptoBoxReady(),
-    redirectUri: redirectUri(req),
-    saved: req.query.saved === '1',
-    error: req.query.error || null,
-  });
-});
-
-router.post('/', requireGod, (req, res) => {
-  if (!gate(req, res)) return;
-  if (!cryptoBoxReady()) return res.redirect('/admin/paid?error=' + encodeURIComponent('De encryptiesleutel kon niet worden aangemaakt of gelezen (schrijfrechten op de opslagmap?); secrets kunnen niet veilig worden opgeslagen.'));
-  const b = req.body || {};
-  const eur = String(b.default_min_eur || '').replace(',', '.').trim();
-  const cents = eur ? Math.round(parseFloat(eur) * 100) : undefined;
-  try {
-    PaidPatreon.saveOwnerConfig(res.locals.site.id, {
-      clientId: (b.client_id || '').trim() || undefined,
-      // Empty secret/token fields keep the stored value (no re-paste needed).
-      clientSecret: (b.client_secret || '').trim() || undefined,
-      campaignId: (b.campaign_id || '').trim() || undefined,
-      accessToken: (b.access_token || '').trim() || undefined,
-      refreshToken: (b.refresh_token || '').trim() || undefined,
-      // Empty clears it (null), a value sets it. Unlike secrets, this is not
-      // sensitive and there's a clear "remove the link" intent.
-      patreonUrl: (b.patreon_url || '').trim() || null,
-      defaultMinCents: Number.isFinite(cents) ? cents : undefined,
-    });
-    return res.redirect('/admin/paid?saved=1');
-  } catch (e) {
-    return res.redirect('/admin/paid?error=' + encodeURIComponent(e.message || 'Opslaan mislukt'));
-  }
-});
-
-router.post('/disconnect', requireGod, (req, res) => {
-  if (!gate(req, res)) return;
-  PaidPatreon.disconnect(res.locals.site.id);
-  res.redirect('/admin/paid?saved=1');
-});
-
-export default router;
Index: src/routes/admin-patreon.js
===================================================================
--- src/routes/admin-patreon.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,59 +1,0 @@
-/**
- * Admin: Link Patreon for the premium layer (god-only).
- *
- * GET /admin/patreon/connect    -> redirect the admin to the license server
- *                                  (oauth/start) with our callback as return URL.
- * GET /admin/patreon/callback   -> license server returns with ?klonkt_token
- *                                  (or ?klonkt_error). Verify + store.
- * GET /admin/patreon/disconnect -> clear entitlement.
- *
- * The real monetisation lock is in the signed token (only the
- * license server can sign). See PatreonService.js.
- */
-
-import express from 'express';
-import { requireGod } from '../middleware/auth.js';
-import {
-  licenseBase, premiumEnabled, verifyEntitlementToken, storeEntitlement, clearEntitlement,
-} from '../services/PatreonService.js';
-
-const router = express.Router();
-router.use(requireGod);
-
-function baseUrl(req) {
-  return (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/$/, '');
-}
-
-router.get('/connect', (req, res) => {
-  if (!premiumEnabled()) return res.redirect('/admin/settings');
-  const ret = baseUrl(req) + '/admin/patreon/callback';
-  res.redirect(`${licenseBase()}/oauth/start?return=${encodeURIComponent(ret)}`);
-});
-
-router.get('/callback', async (req, res) => {
-  if (!premiumEnabled()) return res.redirect('/admin/settings');
-  const { klonkt_token, klonkt_error, klonkt_support_cents } = req.query;
-  if (klonkt_error) {
-    const cents = Number(klonkt_support_cents || 0);
-    const msg = klonkt_error === 'not_entitled'
-      ? `Patreon gekoppeld, maar nog geen $16 lifetime (nu ${(cents / 100).toFixed(2)} aan steun). Steun de campagne en koppel opnieuw.`
-      : 'Patreon-koppeling mislukt.';
-    return res.redirect('/admin/settings?error=' + encodeURIComponent(msg));
-  }
-  try {
-    const payload = await verifyEntitlementToken(String(klonkt_token || ''));
-    if (!payload.entitled) throw new Error('not entitled');
-    storeEntitlement(payload, String(klonkt_token));
-    res.redirect('/admin/settings?success=' + encodeURIComponent('Patreon gekoppeld — premium is actief.'));
-  } catch (e) {
-    console.error('[patreon/callback]', e.message);
-    res.redirect('/admin/settings?error=' + encodeURIComponent('Patreon-token kon niet geverifieerd worden.'));
-  }
-});
-
-router.get('/disconnect', (req, res) => {
-  clearEntitlement();
-  res.redirect('/admin/settings?success=' + encodeURIComponent('Patreon ontkoppeld.'));
-});
-
-export default router;
Index: src/routes/admin-playlists.js
===================================================================
--- src/routes/admin-playlists.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/routes/admin-playlists.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -23,10 +23,12 @@
 import { requireGod } from '../middleware/auth.js';
 import PlaylistService from '../services/PlaylistService.js';
-import { mediaDir } from '../config/paths.js';
 
 // Cover storage — same convention as track covers so a single physical
 // directory holds all album/track artwork. Existing covers in the DB
 // already point at /media/audio-covers/<filename> so we reuse the path.
-const COVER_DIR = mediaDir('COVER_PATH', 'audio-covers');
+const __dirname = path.dirname(new URL(import.meta.url).pathname);
+const COVER_DIR = path.resolve(
+  process.env.COVER_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'audio-covers')
+);
 fs.mkdirSync(COVER_DIR, { recursive: true });
 
@@ -62,7 +64,5 @@
   const playlists = PlaylistService.list(site.id);
   renderPage(req, res, 'pages/admin-playlists', {
-    // admin-playlists neemt de playlist-editor op, dus die module hoort erbij.
-    pageJs: 'admin-playlists playlist-editor',
-    pageTitleKey: 'admin.t_playlists',
+    pageTitle: 'Playlists',
     playlists,
     bodyClass: 'on-admin',
@@ -88,5 +88,5 @@
   const tracks = db.prepare(`
     SELECT t.id, t.title, t.artist, t.duration, t.cover_url,
-           t.link_spotify, t.link_youtube, t.link_soundcloud, m.filename
+           m.filename
     FROM audio_tracks t
     LEFT JOIN media m ON m.id = t.media_id
@@ -103,7 +103,5 @@
       duration: t.duration || 0,
       cover: t.cover_url || '',
-      // Insertable if it has a hosted file OR an external link — a link-only track ([[track:]])
-      // still renders its Spotify/YouTube card on the post, so it must not be disabled in the picker.
-      playable: !!t.filename || !!(t.link_spotify || t.link_youtube || t.link_soundcloud),
+      playable: !!t.filename,
     })),
   });
@@ -114,5 +112,5 @@
   if (!site) return res.status(404).json({ error: 'Site required' });
 
-  // Editor needs the raw track-id list (not stream URLs) — pass no urlFor.
+  // Editor needs the raw track-id list (not signed URLs) — pass no signUrl.
   const playlist = PlaylistService.get(site.id, req.params.id, null);
   if (!playlist) return res.status(404).json({ error: 'Playlist niet gevonden' });
Index: src/routes/admin-push.js
===================================================================
--- src/routes/admin-push.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,23 +1,0 @@
-/**
- * Admin: Notificaties (web push) — the owner's per-device toggle page.
- * Not premium-gated: notifications are infrastructure, not an extra.
- */
-import express from 'express';
-import { renderPage } from '../middleware/render.js';
-import { requireSiteManager } from '../middleware/auth.js';
-import Push from '../services/PushService.js';
-
-const router = express.Router();
-
-router.get('/', requireSiteManager, async (req, res) => {
-  renderPage(req, res, 'pages/admin-push', {
-    pageJs: 'admin-push',
-    pageTitleKey: 'push.t',
-    bodyClass: 'on-admin',
-    vapidKey: await Push.publicKey(),          // null → feature unavailable
-    subscriptions: Push.listSubscriptions(req.session.user.id),
-    defaultAlerts: Push.DEFAULT_ALERTS,
-  });
-});
-
-export default router;
Index: src/routes/admin-seo.js
===================================================================
--- src/routes/admin-seo.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,159 +1,0 @@
-/**
- * Admin: advanced SEO settings for the primary site.
- *
- * GET  /admin/seo   -> form with all SEO/social fields for the main site
- * POST /admin/seo   -> save (god-only)
- *
- * These fields are already consumed by the <head> (shell.ejs) and the JSON-LD/
- * OpenGraph tags, but were previously not editable anywhere. The basic
- * fields (title/bio/robots) remain in Appearance; this is the advanced layer:
- * title template, canonical, social share image, verification metas,
- * publisher/JSON-LD and OpenGraph locale.
- *
- * Operates on the PRIMARY site (solo = the only site; hub = the company site).
- */
-
-import express from 'express';
-import db from '../config/database.js';
-import { renderPage } from '../middleware/render.js';
-import { requireGod } from '../middleware/auth.js';
-import { getPrimarySite } from '../middleware/site.js';
-import { isMbid } from '../services/ap-core.js';
-import MusicBrainz from '../services/MusicBrainzService.js';
-
-const router = express.Router();
-
-function trimOrNull(v, max) {
-  const s = (v == null ? '' : String(v)).trim();
-  return s ? s.slice(0, max) : null;
-}
-
-// ==================== FORM ====================
-router.get('/', requireGod, (req, res) => {
-  const primary = getPrimarySite();
-  if (!primary) {
-    return res.redirect('/admin/sites/new?error=' + encodeURIComponent('Maak eerst een site aan'));
-  }
-  const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(primary.id);
-
-  renderPage(req, res, 'pages/admin-seo', {
-    pageTitleKey: 'admin.t_seo',
-    pageJs: 'admin-seo',
-    bodyClass: 'on-admin',
-    site,
-    success: req.query.success || null,
-    error: req.query.error || null,
-  });
-});
-
-/**
- * "Zoek jezelf op" -- kandidaten uit MusicBrainz (shaer-mbz).
- *
- * De zoekopdracht draait HIER en niet in de browser: MusicBrainz staat een
- * verzoek per seconde toe per APPLICATIE, en dat is alleen af te dwingen als
- * alles langs een plek gaat. Bovendien eisen ze een User-Agent met contact, en
- * die kan een browser niet zetten.
- *
- * Wij kiezen NIET. Ook niet als er precies een treffer is: een verkeerd geraden
- * MBID zet jouw naam onder andermans werk.
- */
-router.get('/api/musicbrainz', requireGod, async (req, res) => {
-  const site = getPrimarySite(req);
-  const q = String(req.query.q || (site && (site.publisher_name || site.title)) || '').trim();
-  if (!q) return res.json({ ok: true, q: '', kandidaten: [] });
-  // Wie zijn id al kent plakt het hier. Een zoekopdracht op een UUID levert bij
-  // MusicBrainz niets op, dus zonder deze tak geeft plakken juist het slechtste
-  // resultaat.
-  if (isMbid(q)) {
-    const een = await MusicBrainz.haalArtiest(q);
-    return res.json({ ok: true, q, kandidaten: een ? [een] : [] });
-  }
-  res.json({ ok: true, q, kandidaten: await MusicBrainz.zoekArtiesten(q) });
-});
-
-/**
- * De terug-weg: noemt de MusicBrainz-pagina ons domein? (shaer-mbz)
- *
- * Een koppeling van onze kant is een bewering -- iedereen kan een id typen.
- * Pas als de artiestenpagina TERUGWIJST is het een paar. Wij zetten die
- * verwijzing niet zelf: dat kan niet via hun API en hoort ook niet, de artiest
- * doet dat op musicbrainz.org onder "social networking".
- */
-router.get('/api/musicbrainz/terugweg', requireGod, async (req, res) => {
-  const mbid = String(req.query.mbid || '').trim().toLowerCase();
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!isMbid(mbid) || !base) return res.json({ ok: true, verified: false, urls: [] });
-  res.json({ ok: true, ...(await MusicBrainz.controleerTerugweg(mbid, base)) });
-});
-
-// ==================== SAVE ====================
-router.post('/', requireGod, (req, res) => {
-  const primary = getPrimarySite();
-  if (!primary) return res.redirect('/admin/seo?error=' + encodeURIComponent('Geen site gevonden'));
-
-  const f = req.body;
-  const schemaType = f.schema_type === 'Organization' ? 'Organization' : 'Person';
-
-  // De MusicBrainz-koppeling (shaer-mbz). Alleen een echte MBID komt de kolom
-  // in: zonder deze zeef sluipt er een URL of een handle in het veld dat naar
-  // buiten gaat, en het gaat naar TWEE uitgangen -- de JSON-LD en de actor.
-  // Leeg is een geldige keuze; dat is ontkoppelen.
-  //
-  // Deze twee regels stonden BINNEN de template-literal hieronder, dus ze waren
-  // geen code maar tekst in de SQL. Daardoor faalde elke opslag op dit paneel
-  // met `near "/": syntax error` -- niet alleen de koppeling, ook het
-  // titelsjabloon, de omschrijving en alle verificatiecodes.
-  const mbRuw = String(f.mb_artist_id || '').trim().toLowerCase();
-  const mbArtistId = isMbid(mbRuw) ? mbRuw : null;
-
-  db.prepare(`
-    UPDATE sites SET
-      robots_index = ?,
-      title_template = ?,
-      canonical = ?,
-      default_description = ?,
-      og_image_default = ?,
-      og_theme = ?,
-      og_locale = ?,
-      author = ?,
-      twitter = ?,
-      facebook_app_id = ?,
-      google_verification = ?,
-      bing_verification = ?,
-      pinterest_verification = ?,
-      yandex_verification = ?,
-      schema_type = ?,
-      publisher_name = ?,
-      publisher_url = ?,
-      publisher_logo = ?,
-      mb_artist_id = ?, mb_artist_name = ?,
-      updated_at = CURRENT_TIMESTAMP
-    WHERE id = ?
-  `).run(
-    f.robots_index ? 1 : 0,
-    (f.title_template || '{title} — {site}').slice(0, 200),
-    trimOrNull(f.canonical, 200),
-    trimOrNull(f.default_description, 500),
-    trimOrNull(f.og_image_default, 500),
-    (f.og_theme === 'light' || f.og_theme === 'dark') ? f.og_theme : null, // null = auto (follow site theme)
-    trimOrNull(f.og_locale, 32),
-    trimOrNull(f.author, 120),
-    trimOrNull(f.twitter, 64),
-    trimOrNull(f.facebook_app_id, 64),
-    trimOrNull(f.google_verification, 200),
-    trimOrNull(f.bing_verification, 200),
-    trimOrNull(f.pinterest_verification, 200),
-    trimOrNull(f.yandex_verification, 200),
-    schemaType,
-    trimOrNull(f.publisher_name, 200),
-    trimOrNull(f.publisher_url, 200),
-    trimOrNull(f.publisher_logo, 500),
-    mbArtistId,
-    mbArtistId ? (String(f.mb_artist_name || '').trim().slice(0, 200) || null) : null,
-    primary.id,
-  );
-
-  res.redirect('/admin/seo?success=' + encodeURIComponent('SEO-instellingen opgeslagen'));
-});
-
-export default router;
Index: src/routes/admin-settings.js
===================================================================
--- src/routes/admin-settings.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,186 +1,0 @@
-/**
- * Admin: global settings.
- *  - hub branding (name/tagline/intro/hero of the generic hub home page)
- *
- * GET  /admin/settings   -> show current settings
- * POST /admin/settings   -> save (god-only). Also accepts an uploaded
- *                           hero image (multipart); an upload wins over the
- *                           URL text field. Without an upload the URL field is leading.
- *
- * The hub page is generic (belonging to no user); this branding lives in
- * global settings, not in a site.
- */
-
-import express from 'express';
-import path from 'path';
-import fs from 'fs';
-import multer from 'multer';
-import { v4 as uuid } from 'uuid';
-import { renderPage } from '../middleware/render.js';
-import { requireGod } from '../middleware/auth.js';
-import { getSetting, setSetting } from '../services/SettingsService.js';
-import { SUPPORTED } from '../services/i18n.js';
-import { mailerStatus, sendMail } from '../config/mailer.js';
-import { entitlementStatus, premiumUnlocked } from '../services/PatreonService.js';
-import { toWebp } from '../services/ImageWebpService.js';
-import { mediaDir } from '../config/paths.js';
-
-const router = express.Router();
-
-// Hero dark overlay: percentage 0-100 (0 = no overlay, 100 = fully black).
-// Default 45 = the old hard-coded value, so existing hubs don't change appearance.
-function clampOverlay(raw) {
-  const v = parseInt(raw, 10);
-  return Number.isFinite(v) ? Math.max(0, Math.min(100, v)) : 45;
-}
-
-// Hero uploads land in storage/media/hero → accessible as /media/hero/<file>
-// (the /media static handler serves storage/media). Same model as avatars.
-const HERO_DIR = mediaDir('HERO_PATH', 'hero');
-fs.mkdirSync(HERO_DIR, { recursive: true });
-
-// Only raster formats for upload. SVG is intentionally NOT allowed via upload
-// (raw SVG can contain scripts → stored-XSS when opened directly); an SVG hero
-// can still be set via the URL field (like the bundled demo placeholder).
-const ALLOWED_HERO_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
-const MAX_HERO_BYTES = 5 * 1024 * 1024;
-
-const heroUpload = multer({
-  storage: multer.diskStorage({
-    destination: (req, file, cb) => cb(null, HERO_DIR),
-    filename: (req, file, cb) => {
-      const ext = path.extname(file.originalname).toLowerCase();
-      cb(null, `${uuid()}${ext}`);
-    },
-  }),
-  limits: { fileSize: MAX_HERO_BYTES },
-  fileFilter: (req, file, cb) => {
-    const ext = path.extname(file.originalname).toLowerCase();
-    if (!ALLOWED_HERO_EXT.has(ext)) {
-      return cb(new Error('Hero-afbeelding moet jpg/png/webp/gif zijn'));
-    }
-    cb(null, true);
-  },
-});
-
-router.get('/', requireGod, (req, res) => {
-  renderPage(req, res, 'pages/admin-settings', {
-    pageJs: 'admin-settings',
-    pageTitleKey: 'admin.t_settings',
-    bodyClass: 'on-admin',
-    hubTagline: getSetting('hub_tagline') || '',
-    hubIntro: getSetting('hub_intro') || '',
-    hubHeroImage: getSetting('hub_hero_image') || '',
-    hubHeroOverlay: clampOverlay(getSetting('hub_hero_overlay')),
-    defaultLang: getSetting('default_lang') || '',
-    premium: entitlementStatus(),
-    smtp: mailerStatus(),
-    footerNewsletter: getSetting('footer_newsletter') === '1',
-    apEnabledSetting: getSetting('ap_enabled', '1') !== '0',
-    success: req.query.success || null,
-    error: req.query.error || null,
-  });
-});
-
-router.post('/', requireGod, (req, res) => {
-  // multer.single processes multipart (hub branding form). For a plain
-  // urlencoded POST: multer does nothing and req.body stays intact.
-  heroUpload.single('hub_hero_file')(req, res, (err) => {
-    if (err) {
-      return res.redirect('/admin/settings?error=' + encodeURIComponent(err.message));
-    }
-
-    if (typeof req.body.default_lang !== 'undefined') {
-      // Default language for visitors (empty = follow env/browser). Validated against NL/EN/DE.
-      const dl = (req.body.default_lang || '').toString().toLowerCase();
-      setSetting('default_lang', SUPPORTED.includes(dl) ? dl : '');
-    }
-    if (typeof req.body.timezone !== 'undefined') {
-      // Site timezone (IANA, e.g. Europe/Amsterdam). Empty = server default (UTC).
-      // Validate with Intl so a nonsense value never breaks date rendering.
-      const tz = (req.body.timezone || '').toString().trim();
-      let valid = '';
-      if (tz) { try { Intl.DateTimeFormat('en-US', { timeZone: tz }); valid = tz; } catch { valid = ''; } }
-      setSetting('timezone', valid);
-    }
-    if (typeof req.body.hub_tagline !== 'undefined') {
-      setSetting('hub_tagline', (req.body.hub_tagline || '').toString().slice(0, 120).trim());
-    }
-    if (typeof req.body.hub_intro !== 'undefined') {
-      setSetting('hub_intro', (req.body.hub_intro || '').toString().slice(0, 400).trim());
-    }
-
-    // Hero: an uploaded image wins; otherwise the URL text field.
-    if (req.file) {
-      const newUrl = `/media/hero/${toWebp(req.file)}`;
-      // Clean up a previously uploaded hero (only if it came from our hero dir).
-      const old = getSetting('hub_hero_image') || '';
-      if (old.startsWith('/media/hero/')) {
-        try { fs.unlinkSync(path.join(HERO_DIR, path.basename(old))); } catch {}
-      }
-      setSetting('hub_hero_image', newUrl);
-    } else if (typeof req.body.hub_hero_image !== 'undefined') {
-      setSetting('hub_hero_image', (req.body.hub_hero_image || '').toString().slice(0, 300).trim());
-    }
-
-    if (typeof req.body.hub_hero_overlay !== 'undefined') {
-      setSetting('hub_hero_overlay', String(clampOverlay(req.body.hub_hero_overlay)));
-    }
-
-    res.redirect('/admin/settings?success=' + encodeURIComponent('Opgeslagen'));
-  });
-});
-
-// ── SMTP / e-mail-instellingen ────────────────────────────────────
-router.post('/smtp', requireGod, (req, res) => {
-  const b = req.body || {};
-  if (b.clear === '1') {
-    ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from'].forEach((k) => setSetting(k, ''));
-    return res.redirect('/admin/settings?success=' + encodeURIComponent('SMTP-instellingen gewist'));
-  }
-  setSetting('smtp_host', (b.smtp_host || '').toString().trim());
-  setSetting('smtp_port', (b.smtp_port || '').toString().trim());
-  setSetting('smtp_user', (b.smtp_user || '').toString().trim());
-  setSetting('smtp_from', (b.smtp_from || '').toString().trim());
-  // Only overwrite the password if a new value was entered.
-  const pass = (b.smtp_pass || '').toString();
-  if (pass) setSetting('smtp_pass', pass);
-  res.redirect('/admin/settings?success=' + encodeURIComponent('SMTP-instellingen opgeslagen'));
-});
-
-// Newsletter sign-up in the footer on/off.
-router.post('/footer', requireGod, (req, res) => {
-  setSetting('footer_newsletter', req.body.footer_newsletter ? '1' : '0');
-  res.redirect('/admin/settings?success=' + encodeURIComponent('Footer-instelling opgeslagen'));
-});
-
-// Site mode: Solo (ap off → no federation, no comments) or Circles (ap on).
-// Driven by a radio (mode=solo|cirkels); legacy ap_enabled checkbox still accepted.
-router.post('/ap', requireGod, (req, res) => {
-  let enabled;
-  if (typeof req.body.mode !== 'undefined') enabled = req.body.mode === 'solo' ? '0' : '1';
-  else enabled = req.body.ap_enabled ? '1' : '0';
-  setSetting('ap_enabled', enabled);
-  res.redirect('/admin/settings?success=' + encodeURIComponent('Modus opgeslagen'));
-});
-
-// Send a test email to a specified address (or the logged-in user).
-router.post('/smtp/test', requireGod, async (req, res) => {
-  const to = ((req.body && req.body.to) || (req.session.user && req.session.user.email) || '').toString().trim();
-  if (!to || to.indexOf('@') === -1) {
-    return res.redirect('/admin/settings?error=' + encodeURIComponent('Geef een geldig test-e-mailadres op.'));
-  }
-  try {
-    await sendMail({
-      to,
-      subject: 'Klonkt — SMTP-test',
-      text: 'Gelukt! Je SMTP-instellingen werken. Dit is een testbericht van je Klonkt-site.',
-      html: '<p>Gelukt! Je <strong>SMTP-instellingen werken</strong>. Dit is een testbericht van je Klonkt-site.</p>',
-    });
-    res.redirect('/admin/settings?success=' + encodeURIComponent('Testmail verstuurd naar ' + to));
-  } catch (e) {
-    res.redirect('/admin/settings?error=' + encodeURIComponent('Testmail mislukt: ' + (e.message || e)));
-  }
-});
-
-export default router;
Index: src/routes/admin-shows.js
===================================================================
--- src/routes/admin-shows.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,103 +1,0 @@
-/**
- * Show agenda (premium feature #8) — admin side.
- *
- *   GET  /admin/shows           -> list + add form
- *   POST /admin/shows           -> add show (optional notify email to subscribers)
- *   POST /admin/shows/:id/delete
- *
- * Premium + site manager. Notify email requires SMTP; without SMTP the show is
- * simply saved (no email sent).
- */
-
-import express from 'express';
-import db from '../config/database.js';
-import { v4 as uuid } from 'uuid';
-import { renderPage } from '../middleware/render.js';
-import { requireSiteManager } from '../middleware/auth.js';
-import { premiumUnlocked } from '../services/PatreonService.js';
-import { mailerConfigured, sendMail } from '../config/mailer.js';
-import { confirmedFor, counts } from '../services/SubscriberService.js';
-import { getSetting, setSetting } from '../services/SettingsService.js';
-import { t, resolveLang } from '../services/i18n.js';
-
-const router = express.Router();
-
-function esc(s) { return String(s || '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
-function fullUrl(req, p) {
-  const base = (process.env.PUBLIC_BASE_URL || ('https://' + (req.get('host') || ''))).replace(/\/$/, '');
-  return base + (req.res.locals.siteUrlBase || '') + p;
-}
-function premiumGate(req, res, next) {
-  if (!premiumUnlocked()) {
-    const lang = resolveLang(req, { defaultLang: getSetting('default_lang') });
-    return res.status(403).send(t(lang, 'aset.premium_gate', { feature: t(lang, 'admin.t_shows') }));
-  }
-  next();
-}
-function render(req, res, extra = {}) {
-  const site = res.locals.site;
-  const shows = db.prepare('SELECT * FROM shows WHERE site_id = ? ORDER BY date DESC, time DESC').all(site.id);
-  renderPage(req, res, 'pages/admin-shows', {
-    pageTitleKey: 'admin.t_shows', bodyClass: 'on-admin',
-    shows, smtp: mailerConfigured(), notifyCount: confirmedFor(site.id, 'notify').length,
-    agendaEnabled: getSetting('agenda_enabled') === '1',
-    ...extra,
-  });
-}
-
-router.get('/', requireSiteManager, premiumGate, (req, res) => {
-  if (!res.locals.site) return res.status(404).send('Geen site.');
-  render(req, res);
-});
-
-router.post('/', requireSiteManager, premiumGate, async (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.status(404).send('Geen site.');
-  const b = req.body || {};
-  const date = (b.date || '').trim();
-  const city = (b.city || '').trim();
-  if (!date || !city) return render(req, res, { msg: 'Datum en plaats zijn verplicht.', msgKind: 'bad' });
-  let ticket = (b.ticket_url || '').trim();
-  if (ticket && !/^https?:\/\//i.test(ticket)) ticket = '';
-
-  db.prepare(`INSERT INTO shows (id, site_id, date, time, city, venue, country, ticket_url, notes)
-              VALUES (?,?,?,?,?,?,?,?,?)`).run(
-    uuid(), site.id, date, (b.time || '').trim() || null, city, (b.venue || '').trim() || null,
-    (b.country || '').trim() || null, ticket || null, (b.notes || '').trim() || null,
-  );
-
-  let sent = 0;
-  if (b.notify && mailerConfigured()) {
-    const subs = confirmedFor(site.id, 'notify');
-    const where = city + (b.venue ? ' — ' + b.venue : '');
-    for (const s of subs) {
-      const unsub = fullUrl(req, '/nieuwsbrief/uitschrijven/' + s.token);
-      try {
-        await sendMail({
-          to: s.email,
-          subject: 'Nieuwe show: ' + where + ' (' + date + ')',
-          text: (site.title || '') + ' speelt op ' + date + ' in ' + where + '.' + (ticket ? ('\nTickets: ' + ticket) : '') + '\n\nUitschrijven: ' + unsub,
-          html: '<p><strong>' + esc(site.title) + '</strong> speelt op <strong>' + esc(date) + '</strong> in ' + esc(where) + '.</p>' +
-                (ticket ? ('<p><a href="' + ticket + '">Tickets</a></p>') : '') +
-                '<p style="color:#888;font-size:12px"><a href="' + unsub + '">Uitschrijven</a></p>',
-        });
-        sent++;
-      } catch { /* skip */ }
-    }
-  }
-  render(req, res, { msg: 'Show toegevoegd.' + (sent ? (' Notify gestuurd naar ' + sent + ' abonnee(s).') : ''), msgKind: 'ok' });
-});
-
-router.post('/toggle', requireSiteManager, premiumGate, (req, res) => {
-  // Show the agenda on the site (Agenda button in the pill + /shows page).
-  setSetting('agenda_enabled', req.body.enabled ? '1' : '0');
-  res.redirect((res.locals.siteUrlBase || '') + '/admin/shows');
-});
-
-router.post('/:id/delete', requireSiteManager, premiumGate, (req, res) => {
-  const site = res.locals.site;
-  if (site) db.prepare('DELETE FROM shows WHERE id = ? AND site_id = ?').run(req.params.id, site.id);
-  res.redirect((res.locals.siteUrlBase || '') + '/admin/shows');
-});
-
-export default router;
Index: src/routes/admin-sites.js
===================================================================
--- src/routes/admin-sites.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/routes/admin-sites.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -17,20 +17,21 @@
 import path from 'path';
 import fs from 'fs';
+import { fileURLToPath } from 'url';
 import multer from 'multer';
 import { v4 as uuid } from 'uuid';
 import db from '../config/database.js';
 import { renderPage } from '../middleware/render.js';
-import { requireGod, requireAuth, requireSiteManagerBySlug } from '../middleware/auth.js';
+import { requireGod } from '../middleware/auth.js';
 import ThemeService from '../services/ThemeService.js';
 import { listPlatforms, PLATFORMS } from '../services/PlatformIcons.js';
-import { toWebp } from '../services/ImageWebpService.js';
-import { mediaDir } from '../config/paths.js';
-import AP from '../services/ActivityPubService.js';
-
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
 // Profile photos share the avatar directory with user avatars — same physical
 // folder, same URL prefix. Filenames are uuid-prefixed so site photos and
 // user avatars never collide.
-const PHOTO_DIR = mediaDir('AVATAR_PATH', 'avatars');
+const PHOTO_DIR = path.resolve(
+  process.env.AVATAR_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'avatars')
+);
 fs.mkdirSync(PHOTO_DIR, { recursive: true });
 
@@ -74,25 +75,4 @@
 }
 
-/**
- * FEP-7628 aliases (alsoKnownAs): one former identity per line, as an actor
- * URL or an @user@host handle. Handles resolve via WebFinger AT SAVE TIME on
- * purpose — a typo'd alias that silently lands on the actor would make a later
- * Move fail at the old server with no hint why. Throws the offending line.
- */
-export async function parseApAliases(raw, ownActorUri) {
-  const lines = String(raw || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
-  if (lines.length > 5) throw new Error(lines[5] + ' (max 5)');
-  const out = [];
-  for (const line of lines) {
-    let uri = null;
-    if (/^https?:\/\//i.test(line)) uri = line;
-    else if (line.includes('@')) uri = await AP.webfingerResolve(line).catch(() => null);
-    if (!uri) throw new Error(line);
-    if (uri === ownActorUri) continue; // claiming yourself adds nothing
-    if (!out.includes(uri)) out.push(uri);
-  }
-  return out;
-}
-
 const router = express.Router();
 
@@ -102,5 +82,5 @@
 // holds the URL string in `profile_photo` — this endpoint just stores the
 // file and hands back a URL that the form can paste into the input field.
-router.post('/upload-photo', requireAuth, (req, res) => {
+router.post('/upload-photo', requireGod, (req, res) => {
   photoUpload.single('photo')(req, res, (err) => {
     if (err) return res.status(400).json({ ok: false, error: err.message });
@@ -108,5 +88,5 @@
     res.json({
       ok: true,
-      url: `/media/avatars/${toWebp(req.file)}`,
+      url: `/media/avatars/${req.file.filename}`,
       size: req.file.size,
       mime: req.file.mimetype,
@@ -117,8 +97,7 @@
 const RESERVED_SITE_SLUGS = new Set([
   'auth', 'admin', 'login', 'register', 'logout', 'archive', 'search',
-  'account', 'sites', 'comments', 'posts', 'media', 'audio',
-  'forum', 'tag', 'user', 'users', 'artiesten', 'leden', 'feed.xml', 'atom.xml', 'sitemap.xml',
+  'account', 'sites', 'comments', 'posts', 'media', 'audio', 'prutter',
+  'forum', 'tag', 'users', 'feed.xml', 'atom.xml', 'sitemap.xml',
   'manifest.webmanifest', 'sw.js', 'favicon.ico', 'favicon.svg', 'assets',
-  'paid', 'push', 'guardian',
 ]);
 
@@ -129,6 +108,6 @@
     tagline: '',
     language: 'nl',
-    palette: 'klonkt',
-    accent: '#e8b04b',
+    palette: 'sage',
+    accent: '#c2410c',
     profile_photo: '',
     profile_enabled: 1,
@@ -137,8 +116,8 @@
     is_public: 1,
     robots_index: 1,
-    require_login_to_comment: 1,
+    require_login_to_comment: 0,
     enable_audio_player: 1,
-    comments_moderation_mode: 'moderate',
-    feed_view_default: 'grid',
+    enable_prutter: 1,
+    feed_view_default: 'timeline',
     feed_view_switch: 1,
     show_search: 1,
@@ -157,37 +136,17 @@
 }
 
-/** Valid user-id for owner assignment, or null if empty/unknown. */
-function validOwnerId(raw) {
-  const id = (raw || '').toString().trim();
-  if (!id) return null;
-  return db.prepare('SELECT 1 FROM users WHERE id = ?').get(id) ? id : null;
-}
-
-/** Grant a user admin rights on a site (idempotent upsert). */
-function grantSiteAdmin(siteId, userId) {
-  db.prepare(`
-    INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')
-    ON CONFLICT(site_id, user_id) DO UPDATE SET role = 'admin'
-  `).run(siteId, userId);
-}
-
-/** Candidate owners for the owner selector field (god-only). */
-function listOwnerCandidates() {
-  return db.prepare('SELECT id, username, role FROM users ORDER BY username').all();
-}
-
 // ==================== LIST ====================
 router.get('/', requireGod, (req, res) => {
   const sites = db.prepare(`
     SELECT s.id, s.slug, s.title, s.description, s.created_at,
-           s.is_public, s.robots_index, s.is_primary,
+           s.is_public, s.robots_index,
            u.username AS owner_username,
            (SELECT COUNT(*) FROM posts WHERE site_id = s.id) AS post_count
     FROM sites s LEFT JOIN users u ON u.id = s.owner_id
-    ORDER BY s.is_primary DESC, s.created_at DESC
+    ORDER BY s.created_at DESC
   `).all();
 
   renderPage(req, res, 'pages/admin-sites', {
-    pageTitleKey: 'admin.t_sites',
+    pageTitle: 'Sites',
     bodyClass: 'on-admin',
     sites,
@@ -200,17 +159,12 @@
 router.get('/new', requireGod, (req, res) => {
   renderPage(req, res, 'pages/admin-site-edit', {
-    pageJs: 'admin-site-edit',
-    pageTitleKey: 'admin.t_newsite',
+    pageTitle: 'New site',
     bodyClass: 'on-admin',
     isNew: true,
-    // ?owner=<id> (from the users page: "give this user a Klonkt") is
-    // pre-selected; otherwise defaults to the creating god.
-    site: { slug: '', owner_id: validOwnerId(req.query.owner) || req.session.user.id, ...siteEditableFields() },
-    users: listOwnerCandidates(),
+    site: { slug: '', ...siteEditableFields() },
     palettes: ThemeService.listPalettes(),
     accents: ThemeService.listAccents(),
     platforms: listPlatforms(),
     parsedLinks: [],
-    apAliases: '',
     error: null,
   });
@@ -232,10 +186,4 @@
 
   const f = { ...siteEditableFields(), ...req.body };
-
-  // Owner: god may assign the site to a DIFFERENT user — this is the core of
-  // hub mode (each user their own self-managed Klonkt). Empty or invalid → the
-  // creating god themselves.
-  const ownerId = validOwnerId(req.body.owner_id) || req.session.user.id;
-
   const siteId = uuid();
   db.prepare(`
@@ -243,7 +191,6 @@
       id, slug, title, description, tagline, owner_id,
       language, palette, accent, profile_photo,
-      is_public, robots_index, require_login_to_comment, enable_audio_player,
-      feed_view_default
-    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+      is_public, robots_index, require_login_to_comment, enable_audio_player
+    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
   `).run(
     siteId, slug,
@@ -251,8 +198,8 @@
     (f.description || '').slice(0, 500),
     (f.tagline || '').slice(0, 200),
-    ownerId,
+    req.session.user.id,
     f.language || 'nl',
-    f.palette || 'klonkt',
-    ThemeService.validateAccent(f.accent) || '#e8b04b',
+    f.palette || 'sage',
+    ThemeService.validateAccent(f.accent) || '#c2410c',
     f.profile_photo || null,
     f.is_public ? 1 : 0,
@@ -260,16 +207,16 @@
     f.require_login_to_comment ? 1 : 0,
     (f.enable_audio_player !== undefined ? (f.enable_audio_player ? 1 : 0) : 1),
-    f.feed_view_default === 'grid' ? 'grid' : 'reader',
   );
 
-  // The OWNER (not necessarily the creator) gets a site_members admin row → this
-  // lets them pass canAdminSite + requireSiteManager gates to manage their site.
-  grantSiteAdmin(siteId, ownerId);
-
-  res.redirect(`/admin/sites/${slug}/edit?success=` + encodeURIComponent('Site aangemaakt'));
+  // The site_members entry lets the god/owner show up in canAdminSite checks.
+  db.prepare(`
+    INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')
+  `).run(siteId, req.session.user.id);
+
+  res.redirect(`/admin/sites/${slug}/edit?success=` + encodeURIComponent('Site created'));
 });
 
 // ==================== EDIT (form) ====================
-router.get('/:slug/edit', requireSiteManagerBySlug, (req, res) => {
+router.get('/:slug/edit', requireGod, (req, res) => {
   const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(req.params.slug);
   if (!site) return res.redirect('/admin/sites?error=Not+found');
@@ -280,19 +227,13 @@
   }
 
-  let apAliases = '';
-  try { apAliases = (JSON.parse(site.ap_aliases || '[]') || []).join('\n'); } catch { /* show empty on malformed */ }
-
   renderPage(req, res, 'pages/admin-site-edit', {
-    pageJs: 'admin-site-edit',
-    pageTitleKey: 'admin.t_editsite', pageTitleVars: { title: site.title },
+    pageTitle: `Edit: ${site.title}`,
     bodyClass: 'on-admin',
     isNew: false,
     site,
-    users: listOwnerCandidates(),
     palettes: ThemeService.listPalettes(),
     accents: ThemeService.listAccents(),
     platforms: listPlatforms(),
     parsedLinks,
-    apAliases,
     success: req.query.success || null,
     error: req.query.error || null,
@@ -300,56 +241,13 @@
 });
 
-// ==================== MOVE (FEP-7628, slice 2) ====================
-// The explicit departure: announce to every follower that this account now
-// lives elsewhere. Deliberately its own POST with its own button, never a
-// side effect of Save: a Move is a door you close behind you.
-router.post('/:slug/move', requireSiteManagerBySlug, async (req, res) => {
-  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(req.params.slug);
+// ==================== SAVE ====================
+router.post('/:slug/save', requireGod, (req, res) => {
+  const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(req.params.slug);
   if (!site) return res.redirect('/admin/sites?error=Not+found');
-  const r = await AP.moveAccount(site, req.body.move_target || '');
-  if (r && r.ok) {
-    return res.redirect(`/admin/sites/${req.params.slug}/edit?success=` + encodeURIComponent(`Verhuizing aangekondigd naar ${r.target} (${r.inboxes} inboxen).`));
-  }
-  const msg = {
-    guarded_account: 'Dit account heeft guardians; verhuizen kan pas als de guardianship mee kan (shaer-tge).',
-    no_backreference: 'Het nieuwe profiel claimt dit account niet in zijn aliassen. Zet daar eerst dit adres als alias.',
-    not_found: 'Nieuw adres niet gevonden. Gebruik @naam@server of een actor-URL.',
-    unreachable: 'Het nieuwe profiel is niet bereikbaar.',
-    self: 'Dat is dit account zelf.',
-  }[r && r.error] || 'Verhuizen mislukte; probeer het opnieuw.';
-  res.redirect(`/admin/sites/${req.params.slug}/edit?error=` + encodeURIComponent(msg));
-});
-
-// ==================== SAVE ====================
-router.post('/:slug/save', requireSiteManagerBySlug, async (req, res) => {
-  const site = db.prepare('SELECT id, ap_aliases FROM sites WHERE slug = ?').get(req.params.slug);
-  if (!site) return res.redirect('/admin/sites?error=Not+found');
 
   const f = req.body;
-  // Twee vragen, en ze hingen scheef: dit pad schreef 'timeline' terwijl het
-  // AANMAAKpad 'reader' schreef, voor precies dezelfde keuze. Elke site die ooit
-  // is opgeslagen droeg dus 'timeline', en de client vertaalde dat stil terug.
-  // Nu betekent de waarde weer wat er staat.
-  const feedAlt = ['timeline', 'auto'].includes(f.feed_alt_view) ? f.feed_alt_view : 'reader';
-  const feedViewDef = f.feed_view_default === 'grid' ? 'grid' : feedAlt;
+  const moderationMode = f.comments_moderation_mode === 'moderate' ? 'moderate' : 'trust';
+  const feedViewDef = f.feed_view_default === 'grid' ? 'grid' : 'timeline';
   const profileLinksJson = buildProfileLinks(f);
-
-  // FEP-7628 aliases — validated/resolved before anything is written.
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  // Het INVOERVELD staat hier sinds 14-8 niet meer: aliassen horen bij
-  // Migreren. Dit formulier mag ze dus niet aanraken, en al helemaal niet
-  // leegmaken omdat het veld ontbreekt. Anders verlies je je claim op je oude
-  // account door je kleuren aan te passen, en weigert de Move daarna met
-  // no_backreference. Alleen verwerken als het veld ECHT is meegestuurd, zodat
-  // een oude gecachte pagina die hem nog wel heeft blijft werken.
-  let apAliasesJson = site.ap_aliases || null;
-  if (Object.prototype.hasOwnProperty.call(f, 'ap_aliases')) {
-    try {
-      const arr = await parseApAliases(f.ap_aliases, AP.actorId(base, req.params.slug));
-      apAliasesJson = arr.length ? JSON.stringify(arr) : null;
-    } catch (e) {
-      return res.redirect(`/admin/sites/${req.params.slug}/edit?error=` + encodeURIComponent(`Alias niet herkend of niet vindbaar: ${e.message}`));
-    }
-  }
 
   // theme_override: only accept the three legal values. Empty string means
@@ -359,5 +257,5 @@
   // accent: only accept colors from the curated ACCENTS list. Falls back to
   // the orange default if the submitted value isn't recognised.
-  const accent = ThemeService.validateAccent(f.accent) || '#e8b04b';
+  const accent = ThemeService.validateAccent(f.accent) || '#c2410c';
 
   db.prepare(`
@@ -365,12 +263,14 @@
       title = ?, description = ?, tagline = ?, language = ?,
       palette = ?, accent = ?, theme_override = ?, profile_photo = ?,
-      profile_enabled = ?,
+      profile_enabled = ?, profile_name = ?, profile_bio = ?,
       profile_links = ?,
-      ap_aliases = ?,
       is_public = ?, robots_index = ?, require_login_to_comment = ?,
-      enable_audio_player = ?,
-      approve_followers = ?,
-      feed_view_default = ?, feed_view_switch = ?, feed_alt_view = ?, reader_full_page = ?,
+      enable_audio_player = ?, enable_prutter = ?,
+      comments_moderation_mode = ?,
+      feed_view_default = ?, feed_view_switch = ?,
       show_search = ?, show_archive_link = ?,
+      title_template = ?, twitter = ?, canonical = ?,
+      google_verification = ?, bing_verification = ?,
+      pinterest_verification = ?, yandex_verification = ?,
       custom_css = ?, custom_head_html = ?, custom_foot_html = ?,
       updated_at = CURRENT_TIMESTAMP
@@ -381,22 +281,29 @@
     (f.tagline || '').slice(0, 200),
     f.language || 'nl',
-    f.palette || 'klonkt',
+    f.palette || 'sage',
     accent,
     themeOverride,
     f.profile_photo || null,
     f.profile_enabled ? 1 : 0,
+    (f.profile_name || '').slice(0, 100) || null,
+    (f.profile_bio  || '').slice(0, 500) || null,
     profileLinksJson,
-    apAliasesJson,
     f.is_public ? 1 : 0,
     f.robots_index ? 1 : 0,
     f.require_login_to_comment ? 1 : 0,
     f.enable_audio_player ? 1 : 0,
-    f.approve_followers ? 1 : 0,
+    f.enable_prutter ? 1 : 0,
+    moderationMode,
     feedViewDef,
     f.feed_view_switch ? 1 : 0,
-    feedAlt,
-    f.reader_full_page ? 1 : 0,
     f.show_search ? 1 : 0,
     f.show_archive_link ? 1 : 0,
+    (f.title_template || '{title} — {site}').slice(0, 200),
+    (f.twitter || '').slice(0, 64) || null,
+    (f.canonical || '').slice(0, 200) || null,
+    (f.google_verification    || '').slice(0, 200) || null,
+    (f.bing_verification      || '').slice(0, 200) || null,
+    (f.pinterest_verification || '').slice(0, 200) || null,
+    (f.yandex_verification    || '').slice(0, 200) || null,
     f.custom_css      || null,
     f.custom_head_html || null,
@@ -405,38 +312,5 @@
   );
 
-  // (Re)assign owner — god ONLY. A site-owner editing their own site cannot
-  // change the owner (the field is not shown to non-god users either).
-  if (req.session.user.role === 'god') {
-    const newOwner = validOwnerId(req.body.owner_id);
-    if (newOwner) {
-      db.prepare('UPDATE sites SET owner_id = ? WHERE id = ?').run(newOwner, site.id);
-      grantSiteAdmin(site.id, newOwner);
-    }
-  }
-
-  // Alias change → broadcast an actor Update so remote caches refresh. The old
-  // server re-fetches the actor live during a Move anyway; this is freshness,
-  // not correctness, hence best-effort.
-  if ((site.ap_aliases || null) !== apAliasesJson) {
-    try {
-      const fresh = db.prepare('SELECT * FROM sites WHERE id = ?').get(site.id);
-      AP.deliverActorUpdate(fresh).catch(() => {});
-    } catch { /* never blocks the save */ }
-  }
-
-  res.redirect(`/admin/sites/${req.params.slug}/edit?success=` + encodeURIComponent('Opgeslagen'));
-});
-
-// ==================== MAKE PRIMARY ====================
-// God chooses which site is the primary/main site (the label/company site in hub;
-// in solo mode: the one site). Exactly one site is primary → clear all, then set this one.
-router.post('/:slug/make-primary', requireGod, (req, res) => {
-  const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(req.params.slug);
-  if (!site) return res.redirect('/admin/sites?error=Niet+gevonden');
-  db.transaction(() => {
-    db.prepare('UPDATE sites SET is_primary = 0').run();
-    db.prepare('UPDATE sites SET is_primary = 1 WHERE id = ?').run(site.id);
-  })();
-  res.redirect('/admin/sites?success=' + encodeURIComponent('Primaire site bijgewerkt'));
+  res.redirect(`/admin/sites/${req.params.slug}/edit?success=` + encodeURIComponent('Saved'));
 });
 
Index: src/routes/admin-stats.js
===================================================================
--- src/routes/admin-stats.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,57 +1,0 @@
-/**
- * Admin: Statistics (premium module, god-only).
- *
- * GET /admin/stats -> cookie-free statistics: visitors/views per day,
- *                     plays, and the most popular posts/tracks.
- *
- * Premium-gated via premiumUnlocked() (premium layer off = freely available;
- * on = Patreon required). Tracking is in StatsService (no cookies).
- */
-
-import express from 'express';
-import db from '../config/database.js';
-import { renderPage } from '../middleware/render.js';
-import { requireGod } from '../middleware/auth.js';
-import { premiumUnlocked } from '../services/PatreonService.js';
-import { getStats, currentIp, getExcludedIps, setExcludedIps } from '../services/StatsService.js';
-
-const router = express.Router();
-
-router.get('/', requireGod, (req, res) => {
-  if (!premiumUnlocked()) {
-    return res.status(403).send('Statistieken is een premium-functie — koppel Patreon in Beheer → Instellingen.');
-  }
-  // Link-in-bio clicks (premium #6) for the current site.
-  let linkClicks = [];
-  if (res.locals.site) {
-    try {
-      linkClicks = db.prepare(
-        'SELECT url, clicks FROM link_clicks WHERE site_id = ? AND clicks > 0 ORDER BY clicks DESC LIMIT 50'
-      ).all(res.locals.site.id);
-    } catch { linkClicks = []; }
-  }
-  const days = [7, 14, 30, 90].includes(parseInt(req.query.days, 10)) ? parseInt(req.query.days, 10) : 14;
-  const myIp = currentIp(req);
-  renderPage(req, res, 'pages/admin-stats', {
-    pageTitleKey: 'admin.t_stats',
-    bodyClass: 'on-admin',
-    stats: getStats(days),
-    linkClicks,
-    myIp,
-    ipExcluded: !!myIp && getExcludedIps().includes(myIp),
-  });
-});
-
-// Toggle whether the admin's current IP is counted in statistics.
-router.post('/exclude-ip', requireGod, (req, res) => {
-  const ip = currentIp(req);
-  if (ip) {
-    const list = getExcludedIps();
-    const i = list.indexOf(ip);
-    if (i >= 0) list.splice(i, 1); else list.push(ip);
-    setExcludedIps(list);
-  }
-  res.redirect('/admin/stats');
-});
-
-export default router;
Index: src/routes/admin-updates.js
===================================================================
--- src/routes/admin-updates.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,195 +1,0 @@
-/**
- * Admin: Updates (god-only).
- *   GET  /admin/updates      -> current vs. latest version + status
- *   POST /admin/updates/run  -> fetch latest + restart (fleet only; see below)
- *
- * Three topologies are supported, detected automatically:
- *   - CHECKOUT (external self-hoster): the app dir is itself a git clone with
- *     origin = GitHub. "Latest" = origin/<branch> (fetched on view). Updating is
- *     done out-of-band by `klonkt-update` (needs root for systemd), so the page
- *     shows that command instead of an in-app button.
- *   - BARE (Robin's own VPS fleet): a bare repo at KLONKT_GIT_DIR; the app dir is
- *     a `checkout -f` work-tree (no .git). "Latest" = <branch>. The detached
- *     self-update script runs in-app (no root needed) → the button works.
- *   - ANDROID (the Klonkt phone app, Termux — node reports platform 'android'):
- *     installed from a prebuilt tarball, no git at all. "Latest" = the package
- *     version on the klonkt STABLE branch (the channel the phone tarballs are
- *     built from). The button runs the phone's own `klonkt-update` command
- *     detached, which survives the server restart it causes.
- * git stderr is ignored so a foreign/missing repo never spams "fatal: ...".
- */
-
-import express from 'express';
-import { execFileSync, spawn } from 'child_process';
-import fs from 'fs';
-import path from 'path';
-import { renderPage } from '../middleware/render.js';
-import { requireGod } from '../middleware/auth.js';
-
-const router = express.Router();
-
-const HOME = process.env.HOME || '';
-const APP_DIR = process.cwd();
-const GIT_DIR = process.env.KLONKT_GIT_DIR || path.join(HOME, 'git-repos/prutfolio.git');
-const UPDATE_SCRIPT = process.env.KLONKT_UPDATE_SCRIPT || path.join(HOME, 'bin/klonkt-self-update.sh');
-
-// The app dir is a git CHECKOUT (GitHub install) when it has a .git; otherwise we
-// fall back to the BARE repo (fleet). This split keeps the version check pointed at
-// a repo that actually exists, so it never logs "fatal: not a git repository".
-const IS_CHECKOUT = (() => { try { return fs.existsSync(path.join(APP_DIR, '.git')); } catch { return false; } })();
-
-// A checkout must track the branch it is ACTUALLY on: a self-hoster who checked out
-// `stable` should be compared to origin/stable, not main — otherwise the update panel
-// shows main's commits as "latest" and a bogus "behind" count (confusing for stable
-// users). Env override wins (the bare fleet sets KLONKT_BRANCH); then the checkout's
-// current branch; then main as a last resort.
-const CHECKOUT_BRANCH = IS_CHECKOUT ? (() => {
-  try {
-    const b = execFileSync('git', ['-C', APP_DIR, 'rev-parse', '--abbrev-ref', 'HEAD'],
-      { encoding: 'utf8', timeout: 8000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
-    return (b && b !== 'HEAD') ? b : null;
-  } catch { return null; }
-})() : null;
-const BRANCH = process.env.KLONKT_BRANCH || CHECKOUT_BRANCH || 'main';
-const REMOTE_REF = IS_CHECKOUT ? `origin/${BRANCH}` : BRANCH; // what "latest" resolves to
-
-// The Klonkt Android app (Termux): node there reports platform 'android'; the
-// filesystem check is belt-and-braces for exotic node builds.
-const IS_ANDROID = process.platform === 'android'
-  || (() => { try { return fs.existsSync('/data/data/com.termux/files/usr/bin'); } catch { return false; } })();
-// "Latest" for a phone = what the update button can actually INSTALL: the version
-// of the prebuilt bundle on the release (BUILD-INFO.txt's bundle-version). Reading
-// the stable branch instead showed a new version during the CI window in which the
-// bundle was still being built — pressing update then reinstalled the old version.
-const ANDROID_BUILDINFO_URL = 'https://github.com/roboburr/klonkt-android/releases/download/termux-latest/BUILD-INFO.txt';
-const ANDROID_STABLE_URL = 'https://raw.githubusercontent.com/roboburr/klonkt/stable/package.json';
-
-async function fetchWithTimeout(url, ms) {
-  const ctl = new AbortController();
-  const t = setTimeout(() => ctl.abort(), ms);
-  try { return await fetch(url, { signal: ctl.signal, redirect: 'follow' }); }
-  finally { clearTimeout(t); }
-}
-
-async function androidLatestVersion() {
-  try {
-    const r = await fetchWithTimeout(ANDROID_BUILDINFO_URL, 8000);
-    if (r.ok) {
-      const m = (await r.text()).match(/^bundle-version:\s*(\S+)/m);
-      if (m) return m[1];
-    }
-  } catch { /* fall through */ }
-  // Older releases have no bundle-version line → fall back to the stable branch.
-  try {
-    const r = await fetchWithTimeout(ANDROID_STABLE_URL, 8000);
-    if (!r.ok) return null;
-    return (await r.json()).version || null;
-  } catch { return null; }
-}
-
-function appVersion() {
-  try { return JSON.parse(fs.readFileSync(path.join(APP_DIR, 'package.json'), 'utf8')).version || null; }
-  catch { return null; }
-}
-// stderr is ignored on purpose → a missing/foreign repo fails silently (returns null).
-function git(args) {
-  try {
-    const base = IS_CHECKOUT ? ['-C', APP_DIR] : ['--git-dir', GIT_DIR];
-    return execFileSync('git', [...base, ...args], { encoding: 'utf8', timeout: 8000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
-  } catch { return null; }
-}
-function currentSha() {
-  if (IS_CHECKOUT) return git(['rev-parse', 'HEAD']);
-  try { return fs.readFileSync(path.join(APP_DIR, '.klonkt-version'), 'utf8').trim() || null; } catch { return null; }
-}
-
-// Last 5 commits = the "recent changes" you'll get when updating.
-function recentChanges() {
-  const out = git(['log', '-5', '--format=%s%x1f%cd', '--date=short', REMOTE_REF]);
-  if (!out) return [];
-  return out.split('\n').map((l) => {
-    const i = l.indexOf('\x1f');
-    return i >= 0 ? { msg: l.slice(0, i), date: l.slice(i + 1) } : { msg: l, date: '' };
-  });
-}
-
-router.get('/', requireGod, async (req, res) => {
-  // ANDROID: version-based check against the stable branch; the update button
-  // runs the phone's klonkt-update (always present, the start script writes it).
-  if (IS_ANDROID) {
-    const cur = appVersion();
-    const latest = await androidLatestVersion();
-    return renderPage(req, res, 'pages/admin-updates', {
-      pageTitleKey: 'admin.t_updates',
-      bodyClass: 'on-admin',
-      appVersion: cur,
-      currentSha: cur ? 'v' + cur : null,
-      currentDesc: null,
-      latestSha: latest ? 'v' + latest : null,
-      latestDesc: null,
-      upToDate: !!(cur && latest && cur === latest),
-      canCheck: !!latest,
-      canSelfUpdate: true,
-      manualCommand: null,
-      behind: null,
-      changes: [],
-      success: req.query.success || null,
-      error: req.query.error || null,
-    });
-  }
-  // For a GitHub checkout, refresh the remote ref so "latest" is current. Quiet +
-  // shallow; offline just leaves the last-known ref. stderr ignored (no log noise).
-  if (IS_CHECKOUT) {
-    try { execFileSync('git', ['-C', APP_DIR, 'fetch', '--quiet', '--depth', '1', 'origin', BRANCH], { timeout: 20000, stdio: 'ignore' }); } catch { /* offline / no remote */ }
-  }
-  const cur = currentSha();
-  const latest = git(['rev-parse', REMOTE_REF]);
-  // The in-app "Update now" button only works with the detached self-update script
-  // (the fleet). A systemd install updates via `klonkt-update` (root) → show that.
-  const canSelfUpdate = (() => { try { return fs.existsSync(UPDATE_SCRIPT); } catch { return false; } })();
-  renderPage(req, res, 'pages/admin-updates', {
-    pageTitleKey: 'admin.t_updates',
-    bodyClass: 'on-admin',
-    appVersion: appVersion(),
-    currentSha: cur ? cur.slice(0, 8) : null,
-    currentDesc: cur ? git(['log', '-1', '--format=%s · %cd', '--date=short', cur]) : null,
-    latestSha: latest ? latest.slice(0, 8) : null,
-    latestDesc: latest ? git(['log', '-1', '--format=%s · %cd', '--date=short', REMOTE_REF]) : null,
-    upToDate: !!(cur && latest && cur === latest),
-    canCheck: !!latest,
-    canSelfUpdate,
-    manualCommand: (!canSelfUpdate && IS_CHECKOUT) ? 'sudo klonkt-update' : null,
-    behind: (cur && latest && cur !== latest) ? git(['rev-list', '--count', cur + '..' + REMOTE_REF]) : null,
-    changes: recentChanges(),
-    success: req.query.success || null,
-    error: req.query.error || null,
-  });
-});
-
-router.post('/run', requireGod, (req, res) => {
-  // ANDROID: run the phone's updater detached. It kills node (this process),
-  // swaps the code while keeping storage/.env, and restarts everything — the
-  // detached shell survives the pkill because it isn't a node process.
-  if (IS_ANDROID) {
-    try {
-      const child = spawn('bash', ['-c', 'klonkt-update >> "$HOME/klonkt-update.log" 2>&1'], { detached: true, stdio: 'ignore' });
-      child.unref();
-    } catch (e) {
-      return res.redirect('/admin/updates?error=' + encodeURIComponent('Kon update niet starten: ' + (e.message || e)));
-    }
-    return res.redirect('/admin/updates?success=' + encodeURIComponent('Bijwerken gestart — de site is ~1 minuut bezig (downloaden + herstarten). Ververs daarna deze pagina.'));
-  }
-  if (!fs.existsSync(UPDATE_SCRIPT)) {
-    return res.redirect('/admin/updates?error=' + encodeURIComponent('In-app updaten is hier niet beschikbaar — werk bij met `klonkt-update` op de server.'));
-  }
-  try {
-    // Detached + unlinked: survives the reload that restarts this app.
-    const child = spawn('bash', [UPDATE_SCRIPT, APP_DIR], { detached: true, stdio: 'ignore' });
-    child.unref();
-  } catch (e) {
-    return res.redirect('/admin/updates?error=' + encodeURIComponent('Kon update niet starten: ' + (e.message || e)));
-  }
-  res.redirect('/admin/updates?success=' + encodeURIComponent('Bijwerken gestart — de site herstart over ~10 seconden. Ververs daarna deze pagina.'));
-});
-
-export default router;
Index: src/routes/admin-users.js
===================================================================
--- src/routes/admin-users.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/routes/admin-users.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -20,8 +20,5 @@
 const router = express.Router();
 
-// 'kijker' = read-only demo/audit account: may view everything (incl. admin panel),
-// but the global guard blocks all mutations. Replaces the old separate
-// 'kijk-modus' flag (readonly), which is now covered by this role.
-const VALID_ROLES = new Set(['kijker', 'member', 'admin', 'god']);
+const VALID_ROLES = new Set(['member', 'admin', 'god']);
 
 function godCount() {
@@ -32,5 +29,5 @@
 router.get('/', requireGod, (req, res) => {
   const users = db.prepare(`
-    SELECT u.id, u.username, u.email, u.role, u.created_at, u.avatar_url, u.readonly,
+    SELECT u.id, u.username, u.email, u.role, u.created_at, u.avatar_url,
            (SELECT COUNT(*) FROM posts p WHERE p.author_id = u.id) AS post_count,
            (SELECT COUNT(*) FROM sites s  WHERE s.owner_id  = u.id) AS site_count
@@ -40,5 +37,5 @@
 
   renderPage(req, res, 'pages/admin-users', {
-    pageTitleKey: 'admin.t_users',
+    pageTitle: 'Users',
     bodyClass: 'on-admin',
     users,
@@ -69,7 +66,5 @@
   }
 
-  // readonly=0: read-only status now lives entirely in the 'kijker' role, so
-  // on every role change we clear the legacy flag (no dual source of truth).
-  db.prepare('UPDATE users SET role = ?, readonly = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
+  db.prepare('UPDATE users SET role = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
     .run(newRole, userId);
   res.redirect('/admin/users?success=' + encodeURIComponent('Role updated'));
@@ -89,33 +84,22 @@
   }
 
-  // Cascade delete: this user's sites (+ posts/playlists/audio/members/
-  // comments under them), their own content elsewhere, then the user themselves.
-  // Atomic in a transaction — if any FK fails, everything rolls back.
-  const del = db.transaction(() => {
-    const sites = db.prepare('SELECT id FROM sites WHERE owner_id = ?').all(userId).map((s) => s.id);
-    for (const sid of sites) {
-      db.prepare('DELETE FROM comments WHERE post_id IN (SELECT id FROM posts WHERE site_id = ?)').run(sid);
-      db.prepare('DELETE FROM posts WHERE site_id = ?').run(sid);
-      db.prepare('DELETE FROM playlists WHERE site_id = ?').run(sid);
-      db.prepare('DELETE FROM audio_tracks WHERE site_id = ?').run(sid);
-      db.prepare('DELETE FROM site_members WHERE site_id = ?').run(sid);
-      db.prepare('DELETE FROM sites WHERE id = ?').run(sid);
-    }
-    // Own content on other sites + loose associations.
-    db.prepare('DELETE FROM comments WHERE post_id IN (SELECT id FROM posts WHERE author_id = ?)').run(userId);
-    db.prepare('DELETE FROM posts WHERE author_id = ?').run(userId);
-    db.prepare('DELETE FROM comments WHERE author_id = ?').run(userId);
-    db.prepare('DELETE FROM site_members WHERE user_id = ?').run(userId);
-    db.prepare('DELETE FROM users WHERE id = ?').run(userId);
-  });
+  const owned = db.prepare(`
+    SELECT
+      (SELECT COUNT(*) FROM posts WHERE author_id = ?) AS posts,
+      (SELECT COUNT(*) FROM sites WHERE owner_id  = ?) AS sites
+  `).get(userId, userId);
 
-  try {
-    del();
-  } catch (e) {
-    console.error('[admin/users delete]', e.message);
-    return res.redirect('/admin/users?error=' + encodeURIComponent('Verwijderen mislukt (mogelijk gekoppelde data).'));
+  if (owned.posts > 0 || owned.sites > 0) {
+    return res.redirect('/admin/users?error=' + encodeURIComponent(
+      `Cannot delete: user owns ${owned.sites} site(s) and ${owned.posts} post(s). Reassign or delete those first.`
+    ));
   }
 
-  res.redirect('/admin/users?success=' + encodeURIComponent('Gebruiker verwijderd: ' + target.username));
+  // Clean up dangling references
+  db.prepare('DELETE FROM site_members WHERE user_id = ?').run(userId);
+  db.prepare('DELETE FROM comments WHERE author_id = ?').run(userId);
+  db.prepare('DELETE FROM users WHERE id = ?').run(userId);
+
+  res.redirect('/admin/users?success=' + encodeURIComponent('User deleted: ' + target.username));
 });
 
Index: src/routes/admin.js
===================================================================
--- src/routes/admin.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/routes/admin.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -6,59 +6,11 @@
 
 import express from 'express';
-import db, { isoSql } from '../config/database.js';
+import db from '../config/database.js';
 import { renderPage } from '../middleware/render.js';
-import { requireAuth } from '../middleware/auth.js';
-import { apEnabled } from '../services/SettingsService.js';
-import { getPrimarySite } from '../middleware/site.js';
+import { requireGod } from '../middleware/auth.js';
 
 const router = express.Router();
 
-// Recent posts from one site, DRAFTS ON TOP, with mode-aware edit/view URLs.
-// Solves the problem that drafts (status != published) were not findable anywhere:
-// the timeline shows only published posts.
-function sitePosts(siteId, siteSlug, limit = 60) {
-  const base = '';
-  return db.prepare(`
-    SELECT slug, title, status, published_at, created_at, updated_at
-    FROM posts WHERE site_id = ?
-    ORDER BY (status != 'published') DESC, ${isoSql('COALESCE(updated_at, published_at, created_at)')} DESC
-    LIMIT ?
-  `).all(siteId, limit).map((p) => ({
-    ...p,
-    isDraft: p.status !== 'published',
-    editUrl: `${base}/posts/${p.slug}/edit`,
-    viewUrl: `${base}/${p.slug}`,
-  }));
-}
-
-router.get('/', requireAuth, (req, res) => {
-  const user = req.session.user;
-
-  // A kijker may view the full (god) admin panel read-only — same as god,
-  // but writing is globally blocked. A regular artist who owns a site gets
-  // a "My Klonkt Hub" dashboard, scoped to their own site. No site -> no admin.
-  if (user.role !== 'god' && user.role !== 'kijker') {
-    const mySite = db.prepare(
-      'SELECT * FROM sites WHERE owner_id = ? ORDER BY created_at ASC LIMIT 1'
-    ).get(user.id);
-    if (!mySite) return res.status(403).send('Geen beheer beschikbaar voor dit account.');
-
-    const mine = {
-      posts: db.prepare("SELECT COUNT(*) AS c FROM posts WHERE site_id = ?").get(mySite.id).c,
-      published: db.prepare("SELECT COUNT(*) AS c FROM posts WHERE site_id = ? AND status = 'published'").get(mySite.id).c,
-    };
-    return renderPage(req, res, 'pages/my-site', {
-      pageTitleKey: 'admin.t_hub',
-      bodyClass: 'on-admin',
-      mySite,
-      mine,
-      posts: sitePosts(mySite.id, mySite.slug),
-    });
-  }
-
-  // THE site: it provides the "Appearance" tile with its edit link and the
-  // posts/drafts list.
-  const primarySite = getPrimarySite();
-
+router.get('/', requireGod, (req, res) => {
   const stats = {
     users: db.prepare('SELECT COUNT(*) AS c FROM users').get().c,
@@ -70,24 +22,25 @@
   };
 
-  // Posts/drafts of the site. Drafts are listed first so they are easy to find.
-  const posts = primarySite ? sitePosts(primarySite.id, primarySite.slug) : [];
+  const sites = db.prepare(`
+    SELECT s.slug, s.title, s.created_at, u.username AS owner_username
+    FROM sites s
+    LEFT JOIN users u ON u.id = s.owner_id
+    ORDER BY s.created_at DESC
+    LIMIT 50
+  `).all();
+
+  const users = db.prepare(`
+    SELECT username, email, role, created_at
+    FROM users
+    ORDER BY created_at DESC
+    LIMIT 50
+  `).all();
 
   renderPage(req, res, 'pages/admin', {
-    pageTitleKey: 'admin.t_admin',
+    pageTitle: 'Admin',
     bodyClass: 'on-admin',
-    circlesOn: apEnabled(),   // federatie aan/uit in de tagline
-    primarySite,
     stats,
-    posts,
-  });
-});
-
-// Handleiding — searchable explanation of all admin features. Visible to anyone
-// who may view the admin panel (logged in); purely static help text, nothing sensitive.
-router.get('/handleiding', requireAuth, (req, res) => {
-  renderPage(req, res, 'pages/admin-help', {
-    pageJs: 'admin-help',
-    pageTitleKey: 'admin.t_manual',
-    bodyClass: 'on-admin',
+    sites,
+    users,
   });
 });
Index: src/routes/audio.js
===================================================================
--- src/routes/audio.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/routes/audio.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,25 +1,11 @@
 /**
- * Audio streaming routes — byte-range streaming.
+ * Audio streaming routes — v9-style signed URL + byte-range support.
  *
- * Files live in storage/audio/ and are NOT served by the static /media
- * handler — every fetch goes through this route, which adds byte-range
- * support so HTML5 <audio> can seek.
+ * Files live in storage/media/audio/ and are NOT served by the static
+ * /media handler — every fetch must go through this verified route.
  *
- * GET /audio/stream/:filename
- *   Streams the file with byte-range support.
- *
- * ANTI-THEFT (Spotify-flavoured, step 1 — 2026-05-20):
- *   The player never exposes this URL to the user — it fetch()es the bytes
- *   and plays from a blob: object URL (no shareable link, no "save audio as").
- *   This route additionally refuses anything that isn't a same-origin browser
- *   fetch, so the raw URL can't be pasted into the address bar, hotlinked from
- *   another site, or pulled with curl/yt-dlp.
- *
- *   A request is allowed when EITHER:
- *     - it carries the X-Audio-Player header (our fetch sets it), OR
- *     - Sec-Fetch-Site is same-origin/same-site (covers the admin <audio>
- *       preview, which can't set custom headers).
- *   Address-bar paste sends Sec-Fetch-Site: none; hotlinks send cross-site;
- *   curl/yt-dlp send neither signal → all rejected.
+ * GET /audio/stream/:filename?t=<hmac>&exp=<unix>
+ *   Verifies the token. If valid, streams the file with byte-range support
+ *   so HTML5 <audio> can seek. Anything invalid returns 403.
  */
 
@@ -28,11 +14,9 @@
 import path from 'path';
 import { fileURLToPath } from 'url';
-import db from '../config/database.js';
-import { recordPlay } from '../services/StatsService.js';
-import AP from '../services/ActivityPubService.js';
+import { verifyToken } from '../services/AudioStreamService.js';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 // Audio files live OUTSIDE storage/media — the public /media static handler
-// cannot reach them. Every fetch must go through this gated route.
+// cannot reach them. Every fetch must go through this signed route.
 const AUDIO_DIR = path.resolve(
   process.env.AUDIO_PATH || path.join(__dirname, '..', '..', 'storage', 'audio')
@@ -55,59 +39,15 @@
 };
 
-// Access gate: same-origin browser fetches / media loads — PLUS fediverse-shared tracks.
-function isAllowedAudioRequest(req, filename) {
-  if (req.get('X-Audio-Player') === '1') return true;  // our blob fetch
-  const site = req.get('Sec-Fetch-Site');              // set by modern browsers
-  if (site === 'same-origin' || site === 'same-site') return true;
-  // fedi_open tracks are deliberately served ungated so remote servers (Mastodon, …) can
-  // fetch + play the file inline. The operator opted this specific track in (per-track flag).
-  if (filename) {
-    try {
-      const r = db.prepare(`SELECT 1 FROM audio_tracks t JOIN media m ON t.media_id = m.id
-        WHERE t.fedi_open = 1 AND (m.storage_path = ? OR m.storage_path LIKE ?) LIMIT 1`).get(filename, '%' + filename);
-      if (r) return true;
-    } catch { /* ignore */ }
-  }
-  return false;
-}
-
-/**
- * FEP-1580: de instantie waar dit account naartoe verhuisd is mag ALLE audio
- * ophalen, ook wat niet fedi_open is.
- *
- * Zonder deze tak ziet de nieuwe Klonkt de tracklijst wel en krijgt hij de
- * bestanden niet, en dan verhuis je een bibliotheek met alleen titels. Dat is
- * precies de halve waarheid die deze hele ronde moest opruimen.
- *
- * Smal gehouden: een geldige handtekening, van precies de actor in moved_to, en
- * alleen voor een bestand dat van DIE site is. moved_to komt er alleen te staan
- * als de doel-actor ons in alsoKnownAs had, dus er heeft iemand met beheer aan
- * beide kanten ja gezegd.
- */
-async function isMoveTargetAudio(req, filename) {
-  if (!req.headers['signature'] || !filename) return false;
-  let rij;
-  try {
-    rij = db.prepare(`SELECT s.slug FROM audio_tracks t
-                        JOIN media m ON t.media_id = m.id
-                        JOIN sites s ON s.id = t.site_id
-                       WHERE m.storage_path = ? OR m.storage_path LIKE ? LIMIT 1`)
-      .get(filename, `%${filename}`);
-  } catch { return false; }
-  if (!rij || !rij.slug) return false;
-  const v = await AP.verifyRequest(req).catch(() => null);
-  return !!(v && v.id && AP.isMoveTarget(rij.slug, v.id));
-}
-
-router.get('/stream/:filename', async (req, res) => {
+router.get('/stream/:filename', (req, res) => {
   const { filename } = req.params;
-
-  if (!isAllowedAudioRequest(req, filename) && !(await isMoveTargetAudio(req, filename))) {
-    return res.status(403).send('Direct access not allowed');
-  }
+  const { t, exp } = req.query;
 
   // Sanity: no path traversal, no slashes
   if (!filename || filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
     return res.status(400).send('Bad filename');
+  }
+
+  if (!verifyToken(filename, t, exp)) {
+    return res.status(403).send('Invalid or expired token');
   }
 
@@ -131,26 +71,7 @@
   const range = req.headers.range;
 
-  // Statistics: count one play on the initial player fetch (not on scrub/
-  // range continuations; replays within 24h come from the browser cache → no
-  // double counting). Best-effort, must never break the stream.
-  if (req.get('X-Audio-Player') === '1' && (!range || /^bytes=0-/.test(range))) {
-    try {
-      const tr = db.prepare(`
-        SELECT t.id FROM audio_tracks t JOIN media m ON t.media_id = m.id
-        WHERE m.storage_path = ? OR m.storage_path LIKE ? LIMIT 1
-      `).get(filename, '%' + filename);
-      if (tr) recordPlay(tr.id);
-    } catch {}
-  }
-
   // Common headers
   res.setHeader('Content-Type', mime);
   res.setHeader('Accept-Ranges', 'bytes');
-  // Same lesson /media already learned: Helmet's default CORP is same-origin,
-  // and the browser then refuses to hand a cross-origin <audio> the bytes —
-  // the file arrives, the player stays silent. These URLs are precisely what
-  // we advertise in federated Audio objects (Funkwhale, the hub) to be played
-  // elsewhere; WHO may fetch is decided by the gate above, not by CORP.
-  res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
   // Allow the browser to cache the file for a day so play/pause/replay
   // doesn't re-fetch the whole stream every time. `private` keeps it out of
@@ -186,19 +107,3 @@
 });
 
-// Which post contains this track? (for the mini-player → "jump to the post +
-// scroll to the track".) Fetches the newest published post with [[track:<id>]].
-router.get('/track/:id/post', (req, res) => {
-  const id = String(req.params.id || '');
-  if (!/^[A-Za-z0-9_-]+$/.test(id)) return res.status(400).json({ error: 'bad id' });
-  const row = db.prepare(`
-    SELECT p.slug, s.slug AS site_slug
-    FROM posts p JOIN sites s ON s.id = p.site_id
-    WHERE p.status = 'published' AND p.content LIKE ?
-    ORDER BY p.published_at DESC LIMIT 1
-  `).get('%[[track:' + id + ']]%');
-  if (!row) return res.status(404).json({ error: 'not found' });
-  const url = `/${row.slug}`;
-  res.json({ url });
-});
-
 export default router;
Index: src/routes/auth.js
===================================================================
--- src/routes/auth.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/routes/auth.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -6,70 +6,18 @@
 import { renderPage } from '../middleware/render.js';
 import { loginLimiter, registerLimiter } from '../middleware/rate-limit.js';
-import { safeNext, requireAuth } from '../middleware/auth.js';
-import { mailerConfigured, sendMail } from '../config/mailer.js';
-import { resolveLang, t } from '../services/i18n.js';
-import { setSetting } from '../services/SettingsService.js';
+import { safeNext } from '../middleware/auth.js';
 
 const router = express.Router();
 
-// Fixed dummy hash: ensures login always runs one bcrypt comparison, even when the
-// user doesn't exist or has no password — no timing oracle for enumeration.
-const DUMMY_HASH = bcrypt.hashSync('constant-time-login-guard', 10);
-
-// Canonical base URL for links in emails (reset). Building it from headers is
-// spoofable (X-Forwarded-Host); a fixed config eliminates that risk.
-function publicBaseUrl(req) {
-  const cfg = (process.env.PUBLIC_BASE_URL || '').replace(/\/$/, '');
-  if (cfg) return cfg;
-  // Fallback (dev): trust-proxy-sanitised protocol + Host header (NOT the raw
-  // X-Forwarded-Host).
-  return `${req.protocol}://${req.get('host')}`;
-}
-
-function hashToken(raw) {
-  return crypto.createHash('sha256').update(String(raw)).digest('hex');
-}
-
-// First-time setup? Only while there are no users yet may /register create an
-// admin account. Afterwards registration is closed (listeners come via Google).
-function isSetupMode() {
-  return db.prepare('SELECT COUNT(*) AS c FROM users').get().c === 0;
-}
-
 // ==================== LOGIN ====================
-// Single login = admin/owner password (no public/listener login anymore; social
-// interaction happens via the fediverse). /login and /auth/admin both show it.
 router.get('/login', (req, res) => {
   const next = safeNext(req.query.next) || '';
   if (req.session.user) return res.redirect(next || '/');
-  if (isSetupMode()) return res.redirect('/auth/register' + (next ? '?next=' + encodeURIComponent(next) : ''));
   renderPage(req, res, 'pages/auth-login', {
-    pageTitle: 'Inloggen',
-    bodyClass: 'on-special on-auth',
-    error: req.query.error || null,
-    gerr: null,
+    pageTitle: 'Login',
+    bodyClass: 'on-special',
+    error: null,
     success: req.query.success || null,
     username: '',
-    adminLogin: true,
-    googleReady: false,
-    next,
-  });
-});
-
-// Hidden admin login (username + password). Not linked anywhere in the UI —
-// the admin navigates here directly (/auth/admin).
-router.get('/admin', (req, res) => {
-  const next = safeNext(req.query.next) || '';
-  if (req.session.user) return res.redirect(next || '/');
-  if (isSetupMode()) return res.redirect('/auth/register' + (next ? '?next=' + encodeURIComponent(next) : ''));
-  renderPage(req, res, 'pages/auth-login', {
-    pageTitle: 'Beheerder inloggen',
-    bodyClass: 'on-special on-auth',
-    error: req.query.error || null,
-    gerr: null,
-    success: req.query.success || null,
-    username: '',
-    adminLogin: true,
-    googleReady: false,
     next,
   });
@@ -80,100 +28,153 @@
   const next = safeNext(req.body.next) || '';
 
-  // Error display on the (hidden) admin login page: re-show the password
-  // form (adminLogin:true), not the Google-only public page.
-  const renderErr = (error, status = 400) => {
-    res.status(status);
+  if (!username || !password) {
+    res.status(400);
     return renderPage(req, res, 'pages/auth-login', {
-      pageTitle: 'Beheerder inloggen', bodyClass: 'on-special on-auth',
-      error, gerr: null, success: null, username: username || '',
-      adminLogin: true, googleReady: false, next,
-    });
+      pageTitle: 'Login',
+      bodyClass: 'on-special',
+      error: 'Username and password required',
+      username: username || '',
+      next,
+    });
+  }
+
+  const user = db.prepare(
+    'SELECT * FROM users WHERE username = ? OR email = ?'
+  ).get(username, username);
+
+  if (!user || !bcrypt.compareSync(password, user.password_hash)) {
+    res.status(401);
+    return renderPage(req, res, 'pages/auth-login', {
+      pageTitle: 'Login',
+      bodyClass: 'on-special',
+      error: 'Invalid credentials',
+      username: username,
+      next,
+    });
+  }
+
+  req.session.user = {
+    id: user.id,
+    username: user.username,
+    email: user.email,
+    role: user.role,
+    avatar_url: user.avatar_url,
+    palette: user.palette,
+    theme: user.theme,
   };
 
-  if (!username || !password) return renderErr('Gebruikersnaam en wachtwoord vereist');
-
-  const user = db.prepare('SELECT * FROM users WHERE username = ? OR email = ?').get(username, username);
-  // Always one bcrypt comparison (dummy if the user has no usable password)
-  // so response time reveals nothing about whether the account exists.
-  const usable = !!(user && user.password_hash && user.password_hash !== '!google-oauth');
-  const ok = bcrypt.compareSync(password, usable ? user.password_hash : DUMMY_HASH);
-  if (!usable || !ok) return renderErr('Ongeldige inloggegevens', 401);
-
-  req.session.user = {
-    id: user.id, username: user.username, email: user.email, role: user.role,
-    avatar_url: user.avatar_url, palette: user.palette, theme: user.theme,
-    readonly: !!user.readonly,
-  };
   res.redirect(next || '/');
 });
 
-// ==================== FIRST-TIME SETUP (create admin account) ====================
+// ==================== REGISTER ====================
 router.get('/register', (req, res) => {
   const next = safeNext(req.query.next) || '';
   if (req.session.user) return res.redirect(next || '/');
-  // No public registration: only the very first admin may be created here.
-  if (!isSetupMode()) return res.redirect('/auth/login' + (next ? '?next=' + encodeURIComponent(next) : ''));
   renderPage(req, res, 'pages/auth-register', {
-    pageJs: 'auth-register',
-    pageTitle: t(resolveLang(req), 'setup.title'), bodyClass: 'on-special',
-    error: null, username: '', email: '', siteName: '', next,
+    pageTitle: 'Register',
+    bodyClass: 'on-special',
+    error: null,
+    username: '',
+    email: '',
+    next,
   });
 });
 
 router.post('/register', registerLimiter, (req, res) => {
-  const { username, email, password, siteName } = req.body;
+  const { username, email, password } = req.body;
   const next = safeNext(req.body.next) || '';
-  const renderErr = (error) => renderPage(req, res, 'pages/auth-register', {
-    pageJs: 'auth-register',
-    pageTitle: t(resolveLang(req), 'setup.title'), bodyClass: 'on-special',
-    error, username: username || '', email: email || '', siteName: siteName || '', next,
-  });
-
-  // Hard-closed once a user exists — prevents a second "admin" via this route.
-  if (!isSetupMode()) return res.redirect('/auth/login');
-
-  if (!username || !email || !password) return renderErr('Alle velden zijn verplicht');
+
+  // Validation
+  if (!username || !email || !password) {
+    return renderPage(req, res, 'pages/auth-register', {
+      pageTitle: 'Register',
+      bodyClass: 'on-special',
+      error: 'All fields required',
+      username: username || '',
+      email: email || '',
+      next,
+    });
+  }
+
   if (!/^[a-z0-9_-]{3,32}$/i.test(username)) {
-    return renderErr('Gebruikersnaam: 3-32 tekens, letters/cijfers/_/- alleen');
-  }
-  if (password.length < 8) return renderErr('Wachtwoord moet minstens 8 tekens zijn');
-
+    return renderPage(req, res, 'pages/auth-register', {
+      pageTitle: 'Register',
+      bodyClass: 'on-special',
+      error: 'Username: 3-32 characters, letters/numbers/underscore/dash only',
+      username, email, next,
+    });
+  }
+
+  if (password.length < 8) {
+    return renderPage(req, res, 'pages/auth-register', {
+      pageTitle: 'Register',
+      bodyClass: 'on-special',
+      error: 'Password must be at least 8 characters',
+      username, email, next,
+    });
+  }
+
+  // Check uniqueness
+  const existing = db.prepare(
+    'SELECT id FROM users WHERE username = ? OR email = ?'
+  ).get(username, email);
+
+  if (existing) {
+    return renderPage(req, res, 'pages/auth-register', {
+      pageTitle: 'Register',
+      bodyClass: 'on-special',
+      error: 'Username or email already taken',
+      username, email, next,
+    });
+  }
+
+  // Create user
   const userId = uuid();
   const hash = bcrypt.hashSync(password, 10);
-  // The very first user is the administrator (god).
+
+  // First user becomes god
+  const userCount = db.prepare('SELECT COUNT(*) as c FROM users').get().c;
+  const role = userCount === 0 ? 'god' : 'member';
+
   db.prepare(`
     INSERT INTO users (id, username, email, password_hash, role, theme, palette)
-    VALUES (?, ?, ?, ?, 'god', 'dark', 'sage')
-  `).run(userId, username, email, hash);
-
-  // Auto-create a personal site (single-tenant restructure follows later).
-  // Setup wizard: site name + language come from the form; language = the language
-  // the visitor used to fill in the wizard (resolveLang) and becomes the site default.
-  if (!db.prepare('SELECT 1 FROM sites LIMIT 1').get()) {
+    VALUES (?, ?, ?, ?, ?, 'dark', 'sage')
+  `).run(userId, username, email, hash, role);
+
+  // First user gets a personal site auto-created
+  if (userCount === 0) {
     const siteId = uuid();
-    const lang = resolveLang(req);
-    const title = (siteName || '').trim().slice(0, 80) || (username + "'s Site");
+    const siteSlug = username.toLowerCase();
     db.prepare(`
       INSERT INTO sites (id, slug, title, description, owner_id, palette, accent, language)
-      VALUES (?, ?, ?, ?, ?, 'klonkt', '#e8b04b', ?)
-    `).run(siteId, username.toLowerCase(), title, '', userId, lang);
-    db.prepare(`INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')`).run(siteId, userId);
-    try { setSetting('default_lang', lang); } catch (e) { /* non-fatal */ }
-  }
-
-  req.session.user = { id: userId, username, email, role: 'god', palette: 'klonkt', theme: 'dark' };
+      VALUES (?, ?, ?, ?, ?, 'sage', '#c2410c', 'nl')
+    `).run(siteId, siteSlug, username + "'s Site", 'Welcome to my site', userId);
+    
+    db.prepare(`
+      INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')
+    `).run(siteId, userId);
+  }
+
+  req.session.user = {
+    id: userId, username, email, role,
+    palette: 'sage', theme: 'dark',
+  };
+
   res.redirect(next || '/');
 });
 
-// ==================== FORGOT PASSWORD (request) ====================
+// ==================== PASSWORD RESET (request) ====================
 router.get('/reset-request', (req, res) => {
   if (req.session.user) return res.redirect('/');
   renderPage(req, res, 'pages/auth-reset-request', {
-    pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special',
-    error: null, sent: false, devResetUrl: null, mailer: mailerConfigured(),
-  });
-});
-
-router.post('/reset-request', registerLimiter, async (req, res) => {
+    pageTitle: 'Reset password',
+    bodyClass: 'on-special',
+    error: null,
+    sent: false,
+    devResetUrl: null,
+  });
+});
+
+router.post('/reset-request', registerLimiter, (req, res) => {
   const email = (req.body.email || '').trim().toLowerCase();
   let devResetUrl = null;
@@ -182,52 +183,52 @@
     const user = db.prepare('SELECT id, email FROM users WHERE LOWER(email) = ?').get(email);
     if (user) {
-      const token = crypto.randomBytes(32).toString('hex'); // raw: only goes into the mail/link
+      // Generate token (32-byte hex, ~10 min expiry)
+      const token = crypto.randomBytes(32).toString('hex');
       const expires = new Date(Date.now() + 30 * 60 * 1000).toISOString(); // 30 min
-      // Store only the HASH: so DB read access yields no usable token.
       db.prepare('UPDATE users SET reset_token = ?, reset_token_expires = ? WHERE id = ?')
-        .run(hashToken(token), expires, user.id);
-
-      const url = `${publicBaseUrl(req)}/auth/reset/${token}`;
-
-      if (mailerConfigured()) {
-        try {
-          await sendMail({
-            to: user.email,
-            subject: 'Wachtwoord resetten',
-            text: `Reset je wachtwoord via deze link (30 min geldig):\n\n${url}\n\nNiet aangevraagd? Negeer deze mail.`,
-            html: `<p>Reset je wachtwoord via deze link (30 min geldig):</p><p><a href="${url}">${url}</a></p><p>Niet aangevraagd? Negeer deze mail.</p>`,
-          });
-        } catch (e) {
-          console.error('[reset-request] mail faalde:', e.message);
-        }
-      } else if (process.env.NODE_ENV !== 'production') {
-        // Dev without SMTP: show the link in the log + on the page.
-        console.log(`[password-reset] ${user.email} -> ${url}`);
-        devResetUrl = url;
-      } else {
-        // Production without SMTP: NEVER log the token. Refer to the CLI break-glass.
-        console.log(`[password-reset] requested for ${user.email} (no SMTP — use 'npm run reset-admin')`);
-      }
+        .run(token, expires, user.id);
+
+      // No email sender yet — log the reset URL so dev can use it.
+      const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http';
+      const host = req.headers['x-forwarded-host'] || req.get('host');
+      const url = `${proto}://${host}/auth/reset/${token}`;
+      console.log(`[password-reset] ${email} -> ${url}`);
+      if (process.env.NODE_ENV !== 'production') devResetUrl = url;
     }
   }
 
-  // Anti-enumeration: same response regardless of whether the address exists.
+  // Anti-enumeration: same response regardless of whether the email exists.
   renderPage(req, res, 'pages/auth-reset-request', {
-    pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special',
-    error: null, sent: true, devResetUrl, mailer: mailerConfigured(),
-  });
-});
-
-// ==================== RESET PASSWORD (apply) ====================
+    pageTitle: 'Reset password',
+    bodyClass: 'on-special',
+    error: null,
+    sent: true,
+    devResetUrl,
+  });
+});
+
+// ==================== PASSWORD RESET (apply) ====================
 router.get('/reset/:token', (req, res) => {
   const row = db.prepare(`
     SELECT id, username FROM users
-    WHERE reset_token = ? AND datetime(reset_token_expires) > datetime('now')
-  `).get(hashToken(req.params.token));
+    WHERE reset_token = ? AND reset_token_expires > datetime('now')
+  `).get(req.params.token);
+
+  if (!row) {
+    return renderPage(req, res, 'pages/auth-reset', {
+      pageTitle: 'Reset password',
+      bodyClass: 'on-special',
+      error: 'This reset link is invalid or has expired.',
+      token: null,
+      username: null,
+    });
+  }
+
   renderPage(req, res, 'pages/auth-reset', {
-    pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special',
-    error: row ? null : 'Deze reset-link is ongeldig of verlopen.',
-    token: row ? req.params.token : null,
-    username: row ? row.username : null,
+    pageTitle: 'Reset password',
+    bodyClass: 'on-special',
+    error: null,
+    token: req.params.token,
+    username: row.username,
   });
 });
@@ -237,27 +238,43 @@
   const row = db.prepare(`
     SELECT id, username FROM users
-    WHERE reset_token = ? AND datetime(reset_token_expires) > datetime('now')
-  `).get(hashToken(req.params.token));
+    WHERE reset_token = ? AND reset_token_expires > datetime('now')
+  `).get(req.params.token);
 
   const renderError = (msg) => renderPage(req, res, 'pages/auth-reset', {
-    pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special',
-    error: msg, token: row ? req.params.token : null, username: row ? row.username : null,
-  });
-
-  if (!row) return renderError('Deze reset-link is ongeldig of verlopen.');
-  if (!new_password || new_password.length < 8) return renderError('Wachtwoord moet minstens 8 tekens zijn');
-  if (new_password !== confirm) return renderError('Wachtwoorden komen niet overeen');
+    pageTitle: 'Reset password',
+    bodyClass: 'on-special',
+    error: msg,
+    token: row ? req.params.token : null,
+    username: row ? row.username : null,
+  });
+
+  if (!row) return renderError('This reset link is invalid or has expired.');
+  if (!new_password || new_password.length < 8) {
+    return renderError('Password must be at least 8 characters');
+  }
+  if (new_password !== confirm) {
+    return renderError('Passwords do not match');
+  }
 
   const hash = bcrypt.hashSync(new_password, 10);
   db.prepare(`
-    UPDATE users SET password_hash = ?, reset_token = NULL, reset_token_expires = NULL,
-      updated_at = CURRENT_TIMESTAMP WHERE id = ?
+    UPDATE users
+    SET password_hash = ?, reset_token = NULL, reset_token_expires = NULL,
+        updated_at = CURRENT_TIMESTAMP
+    WHERE id = ?
   `).run(hash, row.id);
-  res.redirect('/auth/admin?success=' + encodeURIComponent('Wachtwoord gereset — log nu in.'));
+
+  // Force login (don't auto-log them in — let them prove ownership)
+  res.redirect('/auth/login?success=' + encodeURIComponent('Password reset — please log in.'));
 });
 
 // ==================== LOGOUT ====================
-router.get('/logout', (req, res) => { req.session.destroy(() => res.redirect('/')); });
-router.post('/logout', (req, res) => { req.session.destroy(() => res.redirect('/')); });
+router.get('/logout', (req, res) => {
+  req.session.destroy(() => res.redirect('/'));
+});
+
+router.post('/logout', (req, res) => {
+  req.session.destroy(() => res.redirect('/'));
+});
 
 export default router;
Index: src/routes/changelog.js
===================================================================
--- src/routes/changelog.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,40 +1,0 @@
-/**
- * Public changelog / release page.
- *
- * GET /changelog  -> renders the changelog in the visitor's language:
- *   CHANGELOG.<lang>.md if a translation exists, else CHANGELOG.md (English base).
- */
-
-import express from 'express';
-import fs from 'fs';
-import path from 'path';
-import { fileURLToPath } from 'url';
-import { renderPage } from '../middleware/render.js';
-import { MarkdownService } from '../services/MarkdownService.js';
-import { resolveLang, t } from '../services/i18n.js';
-import { getSetting } from '../services/SettingsService.js';
-
-const router = express.Router();
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
-const ROOT = path.join(__dirname, '..', '..');
-
-router.get('/changelog', (req, res) => {
-  const lang = resolveLang(req, {
-    userLang: req.session && req.session.user && req.session.user.lang,
-    defaultLang: getSetting('default_lang'),
-  });
-  // Visitor's language if a translation exists, else the English base.
-  const files = [path.join(ROOT, `CHANGELOG.${lang}.md`), path.join(ROOT, 'CHANGELOG.md')];
-  let html = '';
-  for (const f of files) {
-    try { html = MarkdownService.render(fs.readFileSync(f, 'utf8')); break; } catch { /* try next */ }
-  }
-  if (!html) html = `<p>${t(lang, 'changelog.empty')}</p>`;
-  renderPage(req, res, 'pages/changelog', {
-    pageTitle: t(lang, 'changelog.title'),
-    bodyClass: 'on-changelog',
-    changelogHtml: html,
-  });
-});
-
-export default router;
Index: src/routes/circle.js
===================================================================
--- src/routes/circle.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,116 +1,0 @@
-/**
- * Circle feed — the artists this site features (auto-boosts), sourced from
- * ActivityPub. Cards link to the source post. Available whenever the site
- * auto-boosts at least one account; otherwise next() -> postsRoutes.
- *   GET /cirkel
- */
-
-import express from 'express';
-import { renderPage } from '../middleware/render.js';
-import { apEnabled } from '../services/SettingsService.js';
-import ActivityPubService from '../services/ActivityPubService.js';
-
-const router = express.Router();
-
-function safeUrl(u) {
-  return typeof u === 'string' && /^https?:\/\//i.test(u) ? u : null;
-}
-/**
- * media_json als ARRAY, of leeg.
- *
- * De catch vangt KAPOTTE json; hij ving niet geldige json van het verkeerde
- * TYPE. Een remote server stuurde media_json = "[]" -- een string MET daarin
- * `[]` -- en JSON.parse geeft dan netjes een string terug. Een string heeft
- * geen .map, en daarmee lag /cirkel op boiert.eu plat: een 500 op de hele
- * pagina door een enkele post (rij 19 van 72).
- *
- * Precies dezelfde fout stond al beschreven en gerepareerd in
- * views/partials/note-body.ejs, waar drie vreemde notes de Krant meenamen.
- * Die reparatie is hier nooit gekomen -- zelfde data, andere route. Wat er
- * binnenkomt is niet van ons, dus de vorm hoort afgedwongen en niet aangenomen.
- */
-function safeJson(s) {
-  try {
-    const v = s ? JSON.parse(s) : [];
-    return Array.isArray(v) ? v : [];
-  } catch { return []; }
-}
-// The cover image + (separately) a cover video from a remote note's media. NEVER use a video/audio
-// item as the cover image — that produced a broken <img> for an animated cover that federated as an
-// MP4 (the video becomes a <video> instead).
-function coverMedia(media_json) {
-  const media = safeJson(media_json).map((m) => ({ ...m, url: safeUrl(m.url) })).filter((m) => m.url);
-  const video = media.find((m) => /video/i.test(m.type || '')) || null;
-  const image = media.find((m) => /image/i.test(m.type || ''))
-    || (media[0] && !/(video|audio)/i.test(media[0].type || '') ? media[0] : null);
-  return { image, video };
-}
-function htmlToText(html) {
-  return String(html || '').replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim();
-}
-// Tidy a plain-text snippet for use as a card title: drop a leading "RE: <url>" (the
-// quote/reply prefix Misskey/Akkoma and some reply federation prepend) and any other
-// leading bare URL, so the title shows the actual prose, not link noise.
-function tidySnippet(text) {
-  return String(text || '')
-    .replace(/^RE:\s*https?:\/\/\S+\s*/i, '')
-    .replace(/^https?:\/\/\S+\s*/i, '')
-    .trim();
-}
-
-const CIRKEL_PAGE = 72; // matches FEED_PAGE in posts.js: divisible by 2/3/4
-
-router.get('/cirkel', (req, res, next) => {
-  const site = res.locals.site;
-  if (!site || !apEnabled() || (ActivityPubService.autoBoostCount(site.slug) === 0 && ActivityPubService.boostedCount(site.slug) === 0)) return next();
-
-  const append = req.query.append === '1';
-  const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
-  const rows = ActivityPubService.getCirkelPosts(site.slug, CIRKEL_PAGE + 1, offset);
-  const hasMore = rows.length > CIRKEL_PAGE;
-  const posts = rows.slice(0, CIRKEL_PAGE).map((r) => {
-    const text = tidySnippet(htmlToText(r.content));
-    // Show ONLY the title (the bold first line a Klonkt note carries), not the whole
-    // body. Title-less notes (e.g. plain Mastodon) fall back to a short text snippet.
-    const titleM = (r.content || '').match(/^\s*<p>\s*<strong>([\s\S]*?)<\/strong>/i);
-    const realTitle = titleM ? htmlToText(titleM[1]).trim() : '';
-    const cover = coverMedia(r.media_json);
-    const name = r.author_name || r.author_handle || 'Onbekend';
-    return {
-      id: 'ap-' + r.id,
-      slug: '',
-      title: realTitle
-        ? (realTitle.length > 90 ? realTitle.slice(0, 90) + '…' : realTitle)
-        : (text ? (text.length > 90 ? text.slice(0, 90) + '…' : text) : name),
-      excerpt: '',
-      cover_image_url: cover.image ? cover.image.url : null,
-      cover_video_url: cover.video ? cover.video.url : null,
-      published_at: r.published,
-      created_at: r.published,
-      type: 'post',
-      tags: '',
-      pinned: 0,
-      isBoost: !!r.boosted, // a post YOU boosted → render in the pinned style with a Boost badge
-      nsfw: r.nsfw ? 1 : 0, // remote sensitive post → blur in the Cirkel (post-card/tile)
-      content_warning: r.cw || '',
-      status: 'published',
-      source_name: name,
-      external_url: safeUrl(r.url),
-    };
-  });
-
-  const moreBase = res.locals.siteUrlBase || '';
-  if (append) {
-    return renderPage(req, res, 'partials/home-append', { posts, hasMore, nextOffset: offset + CIRKEL_PAGE, moreBase, morePath: '/cirkel' });
-  }
-
-  const sites = ActivityPubService.getCirkelMembers(site.slug)
-    .map((s) => ({ name: s.name || 'Onbekend', url: safeUrl(s.url), avatar: safeUrl(s.icon) }));
-
-  renderPage(req, res, 'pages/circle-feed', {
-    pageTitle: 'Cirkel', bodyClass: 'on-cirkel', posts, sites,
-    hasMore, nextOffset: offset + CIRKEL_PAGE, moreBase,
-  });
-});
-
-export default router;
Index: src/routes/comments.js
===================================================================
--- src/routes/comments.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
+++ src/routes/comments.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -0,0 +1,115 @@
+/**
+ * Comments — phase G v1.
+ *
+ * POST /comments              -> create a comment on a post (auth required)
+ * POST /comments/:id/delete   -> delete (own, or god/site-admin)
+ *
+ * Threading: 1 level deep (top-level + replies). Replies-of-replies fold up
+ * into the same parent (UI keeps it shallow).
+ *
+ * Status: auto-approved for logged-in users (trust mode). The schema's
+ * `status` column stays so we can switch to moderation later without changing
+ * shape. Anonymous comments (require_login_to_comment = 0 + no user) come
+ * later — for now we always require login.
+ */
+
+import express from 'express';
+import { v4 as uuid } from 'uuid';
+import db from '../config/database.js';
+import { requireAuth } from '../middleware/auth.js';
+import PermissionsService from '../services/PermissionsService.js';
+
+const router = express.Router();
+
+// Limits
+const MAX_LEN = 4000;
+const MIN_LEN = 1;
+
+router.post('/', requireAuth, (req, res) => {
+  const site = res.locals.site;
+  if (!site) return res.status(404).send('Site required');
+
+  const postSlug = (req.body.post_slug || '').trim();
+  const rawContent = (req.body.content || '').trim();
+  const parentId = (req.body.parent_comment_id || '').trim() || null;
+
+  if (!postSlug) return res.status(400).send('post_slug required');
+  if (rawContent.length < MIN_LEN) return res.status(400).send('Comment cannot be empty');
+  if (rawContent.length > MAX_LEN) return res.status(413).send(`Comment too long (max ${MAX_LEN} chars)`);
+
+  const post = db.prepare(
+    'SELECT id, slug FROM posts WHERE site_id = ? AND slug = ? AND status = ?'
+  ).get(site.id, postSlug, 'published');
+  if (!post) return res.status(404).send('Post not found');
+
+  if (!PermissionsService.canComment(req.session.user, site, post)) {
+    return res.status(403).send('Comments not allowed');
+  }
+
+  // Validate parent (must belong to this post; collapses replies-of-replies
+  // up to the top-level parent so we never go deeper than 1)
+  let resolvedParent = null;
+  if (parentId) {
+    const parent = db.prepare(
+      'SELECT id, parent_comment_id FROM comments WHERE id = ? AND post_id = ?'
+    ).get(parentId, post.id);
+    if (!parent) return res.status(400).send('Invalid parent comment');
+    resolvedParent = parent.parent_comment_id || parent.id;
+  }
+
+  // Status depends on the site's moderation mode.
+  // 'trust'    = auto-approve immediately (default).
+  // 'moderate' = pending until an admin reviews in /admin/comments.
+  // Author is the post author or god → always trusted (no point gatekeeping yourself).
+  const isTrustedAuthor = req.session.user.role === 'god'
+    || req.session.user.id === post.author_id;
+  const status = (site.comments_moderation_mode === 'moderate' && !isTrustedAuthor)
+    ? 'pending'
+    : 'approved';
+
+  const commentId = uuid();
+  db.prepare(`
+    INSERT INTO comments (id, post_id, author_id, parent_comment_id, content, status)
+    VALUES (?, ?, ?, ?, ?, ?)
+  `).run(commentId, post.id, req.session.user.id, resolvedParent, rawContent, status);
+
+  // Where to land after submit:
+  //   approved → scroll to the new comment
+  //   pending  → comments anchor + ?pending=1 query so post page can flash a notice
+  const target = status === 'approved'
+    ? `${res.locals.siteUrlBase || ''}/${post.slug}#comment-${commentId}`
+    : `${res.locals.siteUrlBase || ''}/${post.slug}?pending=1#comments`;
+  if (req.headers['hx-request']) {
+    res.setHeader('HX-Redirect', target);
+    return res.send('OK');
+  }
+  res.redirect(target);
+});
+
+router.post('/:id/delete', requireAuth, (req, res) => {
+  const site = res.locals.site;
+  if (!site) return res.status(404).send('Site required');
+
+  const comment = db.prepare(`
+    SELECT c.id, c.author_id, c.post_id, p.slug AS post_slug
+    FROM comments c JOIN posts p ON p.id = c.post_id
+    WHERE c.id = ? AND p.site_id = ?
+  `).get(req.params.id, site.id);
+
+  if (!comment) return res.status(404).send('Not found');
+  if (!PermissionsService.canDeleteComment(req.session.user, comment, site)) {
+    return res.status(403).send('No permission');
+  }
+
+  // Delete the comment plus any replies that hung off it
+  db.prepare('DELETE FROM comments WHERE id = ? OR parent_comment_id = ?')
+    .run(req.params.id, req.params.id);
+
+  if (req.headers['hx-request']) {
+    res.setHeader('HX-Redirect', `${res.locals.siteUrlBase || ''}/${comment.post_slug}#comments`);
+    return res.send('OK');
+  }
+  res.redirect(`${res.locals.siteUrlBase || ''}/${comment.post_slug}#comments`);
+});
+
+export default router;
Index: src/routes/download.js
===================================================================
--- src/routes/download.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,151 +1,0 @@
-/**
- * Download-for-email (premium feature #2).
- *
- *   GET  /downloads                 -> list of downloadable tracks (premium; 404 otherwise)
- *   GET  /download/:id              -> email capture page for a single track
- *   POST /download/:id              -> save email (-> mailing list) + unlock download
- *   GET  /download/:id/bestand      -> serves the file (session-gated after capture)
- *
- * The fan leaves their email and receives the file; the address is added to the
- * subscribers list (source 'download', single opt-in — no confirm step before the
- * download). Hub: via /user/:slug/... (resolveSite + siteUrlBase).
- */
-
-import express from 'express';
-import path from 'path';
-import fs from 'fs';
-import { fileURLToPath } from 'url';
-import db from '../config/database.js';
-import { renderPage } from '../middleware/render.js';
-import { premiumUnlocked } from '../services/PatreonService.js';
-import { addSubscriber } from '../services/SubscriberService.js';
-import { postNeighbors } from './posts.js';
-
-const router = express.Router();
-
-// If a real (pinned) post with slug 'downloads' exists, the downloads list is
-// effectively attached to that post. We then also show the Newer/Older post nav
-// so the visitor can browse just like on a regular post.
-function downloadsPostNav(req, res) {
-  const site = res.locals.site;
-  if (!site) return {};
-  const post = db.prepare(
-    "SELECT id, slug, pinned FROM posts WHERE site_id = ? AND slug = 'downloads' AND status = 'published'"
-  ).get(site.id);
-  if (!post) return {};
-  try { return postNeighbors(site, post); } catch (e) { return {}; }
-}
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
-const AUDIO_DIR = path.resolve(process.env.AUDIO_PATH || path.join(__dirname, '..', '..', 'storage', 'audio'));
-
-const MIME = { '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.flac': 'audio/flac', '.m4a': 'audio/mp4', '.ogg': 'audio/ogg' };
-const GRACE_MS = 15 * 60 * 1000; // download window after capture
-
-function dlTrack(siteId, id) {
-  return db.prepare(
-    `SELECT t.id, t.title, t.artist, t.cover_url, m.storage_path, m.filename
-       FROM audio_tracks t JOIN media m ON m.id = t.media_id
-      WHERE t.id = ? AND t.site_id = ? AND t.downloadable = 1`
-  ).get(id, siteId);
-}
-function safeName(title, storagePath) {
-  const ext = path.extname(storagePath || '').toLowerCase() || '.mp3';
-  const base = String(title || 'track').replace(/[^a-zA-Z0-9 _.-]/g, '').trim().slice(0, 80) || 'track';
-  return base + ext;
-}
-
-// List of downloadable tracks.
-router.get('/downloads', (req, res, next) => {
-  if (!premiumUnlocked()) return next();
-  const site = res.locals.site;
-  if (!site) return next();
-  const tracks = db.prepare(
-    `SELECT id, title, artist, cover_url FROM audio_tracks
-      WHERE site_id = ? AND downloadable = 1 ORDER BY position ASC, created_at ASC`
-  ).all(site.id);
-  const nav = downloadsPostNav(req, res);
-  renderPage(req, res, 'pages/downloads', {
-    pageTitle: 'Downloads — ' + (site.title || ''),
-    // on-special = compact profile header (like on a post); on-downloads = grey pill
-    // + feature-route behaviour. Together → downloads looks just like a post.
-    bodyClass: 'on-downloads on-special',
-    dlTracks: tracks,
-    newerPost: nav.newerPost || null,
-    olderPost: nav.olderPost || null,
-  });
-});
-
-// Capture page for a single track.
-router.get('/download/:id', (req, res, next) => {
-  if (!premiumUnlocked()) return next();
-  const site = res.locals.site;
-  if (!site) return next();
-  const track = dlTrack(site.id, req.params.id);
-  if (!track) return next();
-  const fan = req.session && req.session.user;
-  renderPage(req, res, 'pages/download', {
-    pageTitle: track.title + ' — download',
-    bodyClass: 'on-download',
-    dlState: 'form',
-    dlTrack: track,
-    dlPrefill: (fan && fan.email && fan.email.includes('@')) ? fan.email : '',
-  });
-});
-
-// Save email + unlock download.
-router.post('/download/:id', (req, res, next) => {
-  if (!premiumUnlocked()) return next();
-  const site = res.locals.site;
-  if (!site) return next();
-  const track = dlTrack(site.id, req.params.id);
-  if (!track) return next();
-  const email = (req.body.email || '').trim();
-  const r = addSubscriber(site.id, email, 'download', { doubleOptin: false });
-  if (!r.ok) {
-    return renderPage(req, res, 'pages/download', {
-      pageTitle: track.title + ' — download', bodyClass: 'on-download',
-      dlState: 'form', dlTrack: track, dlPrefill: email,
-      dlError: r.error === 'invalid_email' ? 'Controleer je e-mailadres.' : 'Er ging iets mis.',
-    });
-  }
-  // Unlock download in the session (short window).
-  if (!req.session.dl) req.session.dl = {};
-  req.session.dl[track.id] = Date.now();
-  renderPage(req, res, 'pages/download', {
-    // De auto-start hoort ALLEEN bij ready: op het formulier zou hij de
-    // e-mailvraag omzeilen. Het script stond v66r shaer-bqr dan ook binnen
-    // de ready-tak van de template.
-    pageJs: 'download',
-    pageTitle: track.title + ' — download', bodyClass: 'on-download',
-    dlState: 'ready', dlTrack: track,
-  });
-});
-
-// Serve the file — only if an email was just submitted (session-gated).
-router.get('/download/:id/bestand', (req, res, next) => {
-  if (!premiumUnlocked()) return next();
-  const site = res.locals.site;
-  if (!site) return next();
-  const track = dlTrack(site.id, req.params.id);
-  if (!track) return next();
-  const ts = req.session && req.session.dl && req.session.dl[track.id];
-  if (!ts || (Date.now() - ts) > GRACE_MS) {
-    return res.status(403).send('Laat eerst je e-mailadres achter om te downloaden.');
-  }
-  // The playable/downloadable file = the BARE filename (storage_path is an
-  // absolute path → fails the slash-guard). Same approach as /audio/stream.
-  const sp = track.filename;
-  if (!sp || sp.includes('/') || sp.includes('\\') || sp.includes('..')) return res.status(400).send('Bad path');
-  const filePath = path.join(AUDIO_DIR, sp);
-  if (!filePath.startsWith(AUDIO_DIR + path.sep)) return res.status(400).send('Bad path');
-  let stat;
-  try { stat = fs.statSync(filePath); } catch { return res.status(404).send('Bestand niet gevonden'); }
-  if (!stat.isFile()) return res.status(404).send('Bestand niet gevonden');
-  const ext = path.extname(sp).toLowerCase();
-  res.setHeader('Content-Type', MIME[ext] || 'application/octet-stream');
-  res.setHeader('Content-Length', stat.size);
-  res.setHeader('Content-Disposition', 'attachment; filename="' + safeName(track.title, sp) + '"');
-  fs.createReadStream(filePath).pipe(res);
-});
-
-export default router;
Index: src/routes/embed.js
===================================================================
--- src/routes/embed.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,62 +1,0 @@
-/**
- * Embeddable player (premium feature #7).
- *
- *   GET /embed   -> a standalone, compact audio player page (no shell),
- *                   intended to be placed in an <iframe> on EXTERNAL sites.
- *
- * The page is served by us (klonkt-origin), so audio requests from within
- * the iframe remain same-origin → the /audio/stream gate lets them through,
- * even when the iframe is on a foreign site. We only override Helmet's frameguard
- * + frame-ancestors so that external sites are allowed to embed us. Hub: /user/:slug/embed.
- */
-
-import express from 'express';
-import db from '../config/database.js';
-import { premiumUnlocked } from '../services/PatreonService.js';
-
-const router = express.Router();
-
-router.get('/embed', (req, res, next) => {
-  if (!premiumUnlocked()) return next();
-  const site = res.locals.site;
-  if (!site) return next();
-
-  // Allow embedding on external sites (override the global frameguard/CSP).
-  res.removeHeader('X-Frame-Options');
-  res.setHeader(
-    'Content-Security-Policy',
-    "default-src 'self'; media-src 'self' blob: https:; img-src 'self' data: https:; style-src 'unsafe-inline'; script-src 'unsafe-inline' 'self'; frame-ancestors *",
-  );
-
-  let tracks = (res.locals.audioTracks || []).map((t) => ({
-    id: t.id, title: t.title, artist: t.artist, duration: t.duration, url: t.media_url,
-  })).filter((t) => t.url);
-
-  // ?post=<slug> → scope the player to that post's tracks (for the fediverse
-  // player card). Resolve [[track]]/[[album]]/[[playlist]] shortcodes → track ids.
-  const postSlug = (req.query.post || '').toString();
-  if (postSlug) {
-    try {
-      const post = db.prepare("SELECT content FROM posts WHERE site_id = ? AND slug = ? AND status = 'published'").get(site.id, postSlug);
-      if (post && post.content) {
-        const ids = []; const seen = new Set();
-        const add = (id) => { if (id && !seen.has(id)) { seen.add(id); ids.push(id); } };
-        for (const m of post.content.matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) add(m[1]);
-        for (const m of post.content.matchAll(/\[\[album:([^\]]+)\]\]/g)) for (const r of db.prepare('SELECT id FROM audio_tracks WHERE site_id = ? AND album = ? ORDER BY position').all(site.id, m[1].trim())) add(r.id);
-        for (const m of post.content.matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) for (const r of db.prepare('SELECT track_id FROM playlist_tracks WHERE playlist_id = ? ORDER BY position').all(m[1])) add(r.track_id);
-        // Strictly scope to this post's tracks — do NOT fall back to all-site
-        // tracks (that showed unrelated songs for a link-only-track post).
-        const byId = new Map(tracks.map((t) => [t.id, t]));
-        tracks = ids.map((id) => byId.get(id)).filter(Boolean);
-      }
-    } catch { /* fall back to the full site player */ }
-  }
-
-  res.render('pages/embed-player', {
-    site,
-    embedTracks: tracks,
-    siteUrlBase: res.locals.siteUrlBase || '',
-  });
-});
-
-export default router;
Index: src/routes/epk.js
===================================================================
--- src/routes/epk.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,78 +1,0 @@
-/**
- * EPK / press kit (premium) — a shareable press page per Klonkt site.
- *
- * GET /pers  (solo) or /user/:slug/pers (hub, via resolveSite + siteUrlBase)
- *   -> clean, public press kit: hero (photo/title/tagline), short bio, top tracks,
- *      recent posts and a contact button. Intended to share with bookers/press.
- *
- * Premium-gated: non-premium instances have NO /pers (next() -> 404 via the
- * catch-all). The PAGE itself is public (no login) so press can view it;
- * only its EXISTENCE is premium. No login email leak: contact goes via an
- * explicitly configured press address (epk_contact, per site) or the site itself.
- */
-
-import express from 'express';
-import db from '../config/database.js';
-import { renderPage } from '../middleware/render.js';
-import { premiumUnlocked } from '../services/PatreonService.js';
-import { getSetting } from '../services/SettingsService.js';
-
-const router = express.Router();
-
-router.get('/pers', (req, res, next) => {
-  if (!premiumUnlocked()) return next();      // no premium -> no press kit
-  const site = res.locals.site;
-  if (!site) return next();
-
-  // Tracks on the press kit: an admin-CHOSEN selection (max 5, in custom order)
-  // if configured; otherwise automatically the top 5 most-listened.
-  let chosenIds = [];
-  try {
-    const raw = JSON.parse(getSetting('epk_tracks_' + site.id, '') || '[]');
-    if (Array.isArray(raw)) chosenIds = raw.filter((x) => typeof x === 'string').slice(0, 5);
-  } catch (e) { /* invalid JSON → fall back to top */ }
-
-  let tracks;
-  if (chosenIds.length) {
-    const ph = chosenIds.map(() => '?').join(',');
-    const rows = db.prepare(
-      `SELECT id, title, artist, duration, cover_url, COALESCE(play_count, 0) AS plays
-         FROM audio_tracks WHERE site_id = ? AND id IN (${ph})`
-    ).all(site.id, ...chosenIds);
-    const byId = new Map(rows.map((r) => [r.id, r]));
-    tracks = chosenIds.map((id) => byId.get(id)).filter(Boolean);  // preserve chosen order
-  } else {
-    tracks = db.prepare(
-      `SELECT title, artist, duration, cover_url, COALESCE(play_count, 0) AS plays
-         FROM audio_tracks
-        WHERE site_id = ?
-        ORDER BY plays DESC, position ASC, created_at ASC
-        LIMIT 5`
-    ).all(site.id);
-  }
-
-  const posts = db.prepare(
-    `SELECT slug, title, created_at
-       FROM posts
-      WHERE site_id = ? AND status = 'published'
-      ORDER BY created_at DESC
-      LIMIT 5`
-  ).all(site.id);
-
-  // Press contact: per-site setting (epk_contact_<siteId>) if present, otherwise
-  // the global epk_contact. NEVER auto-expose the login email.
-  const contact = (getSetting('epk_contact_' + site.id, '') || getSetting('epk_contact', '') || '').trim();
-  // Short press bio: per-site setting, otherwise the site's tagline.
-  const bio = (getSetting('epk_bio_' + site.id, '') || site.tagline || '').trim();
-
-  renderPage(req, res, 'pages/epk', {
-    pageTitle: (site.title || 'Perskit') + ' — Perskit',
-    bodyClass: 'on-epk',
-    epkTracks: tracks,
-    epkPosts: posts,
-    epkContact: contact,
-    epkBio: bio,
-  });
-});
-
-export default router;
Index: src/routes/feed.js
===================================================================
--- src/routes/feed.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/routes/feed.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -12,5 +12,4 @@
 import express from 'express';
 import db from '../config/database.js';
-import { siteOpenTracks } from '../services/ActivityPubService.js';
 
 const router = express.Router();
@@ -44,24 +43,5 @@
 
 // ==================== RSS 2.0 ====================
-/**
- * Twee feeds, een bouwer.
- *
- *   /feed.xml    de site: posts EN open tracks door elkaar, chronologisch
- *   /tracks.xml  alleen de open tracks -- de hele site als muziekkanaal
- *
- * WAAROM DIE TWEEDE (Robins vraag, 10-8). Hij vroeg naar een "hele site, open
- * tracks"-library voor Funkwhale. Als AS2-object helpt zo'n ding daar niet: een
- * Funkwhale-KANAAL wijst nergens naar een library, en hun library is een eigen
- * actor met inbox en sleutel -- de optie die op 7 augustus is afgewezen. Maar de
- * vorm waarin Funkwhale een kanaal WEL uitgeeft is RSS met iTunes en een
- * enclosure per item, en dat is precies wat Klonkt hier al doet. Naast een echte
- * kanaalfeed gelegd (audio.pepemoss.com/api/v1/channels/tnd/rss): dezelfde
- * namespaces, dezelfde enclosure.
- *
- * Het enige verschil was dat onze feed ook gewone posts draagt. Een muziekkanaal
- * met blogberichten ertussen is er geen, dus die feed staat er nu apart -- een
- * URL om te plakken, zonder dat er iets naar buiten geduwd wordt.
- */
-function stuurFeed(req, res, { alleenTracks }) {
+router.get('/feed.xml', (req, res) => {
   const site = res.locals.site;
   if (!site) return res.status(404).send('No site');
@@ -69,42 +49,18 @@
   const origin = siteOrigin(req);
   const base = origin + (res.locals.siteUrlBase || '');
-  const posts = alleenTracks ? [] : postsForFeed(site.id);
+  const posts = postsForFeed(site.id);
+  const lastBuild = posts[0]?.published_at || new Date().toISOString();
 
-  // De tracks die deze site aan de federatie heeft opengezet, elk als eigen
-  // item met een <enclosure> (shaer-0nh). Dat laatste is wat een podcast-app
-  // zoekt: zonder enclosure is een feed voor hem leeg, hoe veel items er ook
-  // in staan -- en de actor adverteert deze feed nu juist als kanaal-feed.
-  //
-  // EEN ITEM PER TRACK, want RSS 2.0 staat maar EEN enclosure per item toe.
-  // Een album in een item proppen zou betekenen dat er van vijf nummers vier
-  // verdwijnen. Dezelfde keuze als in de outbox: de post is het bericht, de
-  // track is de publicatie.
-  //
-  // Welke tracks open zijn beslist de AP-service, niet deze route: dat is een
-  // poortregel en die hoort op een plek te staan.
-  const tracks = siteOpenTracks(site.id);
-  const lastBuild = posts[0]?.published_at || tracks[0]?.created_at || new Date().toISOString();
-
-  // De itunes-velden waar een podcast-app een kanaal aan herkent. Funkwhale
-  // bouwde onze kanaalpagina langs de RSS-kant op en liet de categorie leeg,
-  // want die leest hij hier -- niet uit `category` op de AP-actor.
-  //
-  // De categorie volgt DEZELFDE regel als daar: alleen 'Music' als er ook
-  // werkelijk publieke muziek is. Een blog zonder open track is naar buiten
-  // toe geen muziekkanaal, en gated muziek telt niet mee -- afwezig is
-  // afwezig, ook in een categorie.
-  const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
-  const kanaalKunst = abs(site.profile_photo || site.og_image_default || null);
-  const kanaalTags = [
-    `    <itunes:author>${escapeXml(site.author || site.title || '')}</itunes:author>`,
-    site.description || site.tagline
-      ? `    <itunes:summary>${escapeXml(site.description || site.tagline)}</itunes:summary>` : null,
-    kanaalKunst ? `    <itunes:image href="${escapeXml(kanaalKunst)}" />` : null,
-    tracks.length ? '    <itunes:category text="Music" />' : null,
-  ].filter(Boolean).join('\n');
-
-  const wanneer = (d) => { const t = Date.parse(d); return Number.isNaN(t) ? 0 : t; };
-  const items = [
-    ...posts.map((p) => ({ op: wanneer(p.published_at), xml: `    <item>
+  res.set('Content-Type', 'application/rss+xml; charset=utf-8');
+  res.send(`<?xml version="1.0" encoding="UTF-8"?>
+<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
+  <channel>
+    <title>${escapeXml(site.title)}</title>
+    <link>${escapeXml(base + '/')}</link>
+    <description>${escapeXml(site.description || site.tagline || '')}</description>
+    <language>${escapeXml(site.language || 'nl')}</language>
+    <lastBuildDate>${new Date(lastBuild).toUTCString()}</lastBuildDate>
+    <atom:link href="${escapeXml(base + '/feed.xml')}" rel="self" type="application/rss+xml" />
+${posts.map(p => `    <item>
       <title>${escapeXml(p.title || '(untitled)')}</title>
       <link>${escapeXml(base + '/' + p.slug)}</link>
@@ -113,42 +69,8 @@
       <author>${escapeXml((p.author_email || 'noreply@localhost') + ' (' + p.author_username + ')')}</author>
       <description>${escapeXml(p.excerpt || '')}</description>
-    </item>` })),
-    ...tracks.map((t) => {
-      const fn = t.filename || (t.storage_path || '').split('/').pop();
-      // Geen <link>: Klonkt heeft geen trackpagina, en een post over vijf
-      // nummers is niet de pagina van dit ene nummer. De guid is daarom geen
-      // permalink maar de stabiele AP-id van de track.
-      return { op: wanneer(t.created_at), xml: `    <item>
-      <title>${escapeXml(t.title || 'Audio')}</title>
-      <guid isPermaLink="false">${escapeXml(`${base}/ap/users/${encodeURIComponent(site.slug)}/tracks/${encodeURIComponent(t.id)}`)}</guid>
-      <pubDate>${new Date(t.created_at || Date.now()).toUTCString()}</pubDate>
-      <description>${escapeXml(t.artist || '')}</description>
-      <enclosure url="${escapeXml(`${base}/audio/stream/${encodeURIComponent(fn)}`)}" length="${Number(t.size) || 0}" type="${escapeXml(t.mime_type || 'audio/mpeg')}" />${t.duration ? `
-      <itunes:duration>${Math.round(t.duration)}</itunes:duration>` : ''}${t.artist ? `
-      <itunes:author>${escapeXml(t.artist)}</itunes:author>` : ''}${abs(t.cover_url) ? `
-      <itunes:image href="${escapeXml(abs(t.cover_url))}" />` : ''}
-    </item>` };
-    }),
-  ].sort((a, b) => b.op - a.op).map((x) => x.xml).join('\n');
-
-  res.set('Content-Type', 'application/rss+xml; charset=utf-8');
-  res.send(`<?xml version="1.0" encoding="UTF-8"?>
-<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
-  <channel>
-    <title>${escapeXml(alleenTracks ? `${site.title} \u2014 muziek` : site.title)}</title>
-    <link>${escapeXml(base + '/')}</link>
-    <description>${escapeXml(site.description || site.tagline || '')}</description>
-    <language>${escapeXml(site.language || 'nl')}</language>
-    <lastBuildDate>${new Date(lastBuild).toUTCString()}</lastBuildDate>
-    <atom:link href="${escapeXml(base + (alleenTracks ? '/tracks.xml' : '/feed.xml'))}" rel="self" type="application/rss+xml" />
-${kanaalTags}
-${items}
+    </item>`).join('\n')}
   </channel>
 </rss>`);
-}
-
-router.get('/feed.xml', (req, res) => stuurFeed(req, res, { alleenTracks: false }));
-// De hele site als muziekkanaal: alleen wat op de federatie openstaat.
-router.get('/tracks.xml', (req, res) => stuurFeed(req, res, { alleenTracks: true }));
+});
 
 // ==================== Atom 1.0 ====================
Index: src/routes/guardian.js
===================================================================
--- src/routes/guardian.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,760 +1,0 @@
-/**
- * The Guardian PWA (FEP-633c): a separate, installable corner of Klonkt for
- * guardians. One place to add and manage wards, a message centre for
- * incoming help requests and adoption traffic, and its own push channel
- * (alert types 'help' and 'guardian', web-push slice reused).
- *
- * Everything is scoped to a site the logged-in user OWNS: the guardian acts
- * as one of their own actors (?site=slug picks one when they own several).
- * Views carry no inline scripts (CSP): logic lives in /assets/js/guardian.js.
- */
-import express from 'express';
-import path from 'path';
-import { fileURLToPath } from 'url';
-import db from '../config/database.js';
-import { requireAuth } from '../middleware/auth.js';
-import AP from '../services/ActivityPubService.js';
-import * as Guardianship from '../services/guardianship/index.js';
-import { t as i18nT, resolveLang } from '../services/i18n.js';
-import { injectCspNonce, renderNoteBody, formatDateTime } from '../middleware/render.js';
-import { emojiName } from '../services/NoteRender.js';
-
-const router = express.Router();
-const __dir = path.dirname(fileURLToPath(import.meta.url));
-
-/** The acting site: ?site=slug when owned, else the user's first site. */
-function siteForUser(req) {
-  const userId = req.session.user.id;
-  const want = String(req.query.site || req.body?.site || '').trim();
-  if (want) {
-    const s = db.prepare('SELECT * FROM sites WHERE slug = ? AND owner_id = ?').get(want, userId);
-    if (s) return s;
-  }
-  return db.prepare('SELECT * FROM sites WHERE owner_id = ? ORDER BY id LIMIT 1').get(userId);
-}
-
-/** Everything the dashboard shows, one shape for page and API. */
-function uiStrings(L) {
-  const keys = ['sent', 'sent_retry', 'sending', 'not_found', 'failed', 'network',
-    'pending', 'active', 'retract', 'release', 'release_confirm', 'open', 'push_unavailable',
-    'embeds_on', 'embeds_off', 'embeds_propose', 'embeds_waiting',
-    'accept', 'reject', 'complete', 'awaiting_others', 'coguard',
-    // The per-ward panel: everything about one child in one place.
-    'settings_title', 'panel_open', 'panel_close', 'panel_help', 'panel_help_empty',
-    'panel_follow', 'panel_follow_empty', 'follow_out_line', 'panel_posts', 'panel_posts_empty',
-    'panel_actions', 'badge_help', 'badge_follow', 'badge_follow_one', 'help_empty',
-    // Releasing a ward: a deliberate two-step answer, never one click.
-    'release_title', 'release_effect', 'release_local', 'release_step_down',
-    'release_last', 'release_unknown', 'release_yes', 'release_no',
-    // Availability (FEP-633c 3.6): the dots, the step-away, the lapse.
-    'avail_available', 'avail_away', 'avail_dormant', 'panel_guards', 'panel_guards_remote',
-    'lapse_propose', 'lapse_line', 'lapse_tally', 'lapse_note', 'lapse_agree', 'lapse_disagree', 'voted',
-    'away_title', 'away_sub', 'away_week', 'away_month', 'away_done',
-    // A gated-setting proposal from a fellow guardian (5.6).
-    'gated_title', 'gated_line_on', 'gated_line_off', 'gated_agree', 'gated_disagree',
-    'play_propose', 'play_on', 'play_off',
-    // The status of a proposal this guardian sent (5.6).
-    'prop_line', 'prop_embeds', 'prop_play', 'prop_on', 'prop_off',
-    'prop_st_open', 'prop_st_accepted', 'prop_st_rejected', 'prop_st_expired',
-    'panel_guards_far',
-    // Het gate-paneel per ward (shaer-ahy.1): een rij per gate, met het soort en
-    // de drempel erbij. De namen volgen de catalogus in gated.js.
-    'gate_externalEmbeds', 'gate_externalPlayback', 'gate_externalThreads',
-    // De twee richtingen van §5.3, met woorden die niet op elkaar lijken:
-    // "Volgverzoeken" komt naar het kind toe, "Zelf iemand volgen" gaat ervan
-    // weg. Zonder dat verschil in de tekst zijn de rijen niet uit elkaar te
-    // houden zodra ze naast elkaar staan (shaer-p729).
-    'gate_follows', 'gate_following',
-    'gate_kind_setting', 'gate_kind_perRequest', 'gate_kind_handover',
-    'gate_unknown', 'gate_always', 'gate_threshold', 'gate_threshold_unknown',
-    'gate_irreversible', 'gate_waiting', 'gate_blocked', 'gate_propose',
-    // Oppikken en afhandelen van een hulpvraag (shaer-lgo).
-    'help_pick', 'help_close', 'help_picked_by', 'help_handled_by', 'help_handled_note',
-    'help_close_ask', 'help_close_yes', 'help_just_now', 'help_hours', 'help_days', 'help_former_ward',
-  'warn_reversible', 'warn_irreversible', 'warn_unknown', 'warn_tally_elsewhere', 'warn_decides', 'warn_not_last', 'warn_go', 'warn_back',
-    'help_archive', 'help_archive_hide', 'panel_history',
-    // Het logboek (§4.2): onbekende soorten vallen terug op hun ruwe naam.
-    'log_show', 'log_hide', 'evr_not_a_teapot',
-    'ev_offer_rejected', 'ev_offer_refused', 'ev_committed', 'ev_guardian_left',
-    'ev_coguardian_left', 'ev_gated_outcome', 'ev_lapse_opened',
-    'gate_propose_open', 'gate_propose_close', 'gate_default_off',
-    'gate_images', 'gate_messages', 'gate_compose', 'gate_replies', 'gate_music', 'gate_quoteCards', 'gate_asked',
-    'gate_customEmoji', 'gate_publicProfile', 'gate_accountMove', 'gate_independence',
-    'gate_unavailable', 'gate_planned_note', 'gates_summary', 'gates_show', 'gates_hide'];
-  const s = Object.fromEntries(keys.map((k) => [k, i18nT(L, `guardian.${k}`)]));
-  s.wave = i18nT(L, 'guardian.wave');
-  s.waved = i18nT(L, 'guardian.waved');
-  return s;
-}
-
-function dashboardState(site, L) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const me = AP.actorId(base, site.slug);
-  // EEN weg naar de hulpvragen (Barts 429-jacht, 9-8): dit scherm had een
-  // eigen kopie van de queue-query, met een afkap op 50 -- dus de fix die open
-  // vragen nooit meer afkapt (shaer-6wt) ging aan het paneel voorbij, en juist
-  // de guardian met een caseload zag oude open vragen wegvallen. Nu dezelfde
-  // bron als de apps: open vragen volledig, geschiedenis afgekapt.
-  const helpItems = Guardianship.queues.helpItemsFor(site.slug).map((h) => ({
-    ...h,
-    // The dashboard is built in the browser, so it gets the body finished: the
-    // same partial de Krant and Berichten use. A 🛟 often carries a screenshot
-    // and a link to the post it is about; both belong in the card.
-    body_html: renderNoteBody(h, L),
-    name_html: emojiName(h.actor_name || '', h.actor_emoji_json),
-    // In the site's own timezone, the same as everywhere else in Klonkt. The
-    // PWA used to slice the raw UTC string, so a 20:20 call for help read 18:20.
-    when_text: formatDateTime(h.published || h.created_at),
-  }));
-  return {
-    site: site.slug,
-    me,
-    // Committed wards, each carrying the gated settings a guardian may change.
-    // `embeds` is null for a ward we do not host: that setting lives on the
-    // ward's own server, so we show it as not-adjustable rather than lying.
-    // `guardians` (FEP-633c 3.6): the fellow guardians of a LOCAL ward with
-    // their availability; null for a remote ward, whose server tracks it.
-    wards: Guardianship.listWards(site.slug).map((w) => ({
-      ...w,
-      embeds: wardEmbedSetting(w.other_uri),
-      playback: wardPlaybackSetting(w.other_uri),
-      guardians: Guardianship.queues.wardGuardianStatuses(w.other_uri),
-      // What THIS guardian proposed for this ward and how it stands (5.6):
-      // open, accepted, rejected, or expired when the window ran out and the
-      // ward's server had nothing to write home. The answer is a real
-      // Accept/Reject from the ward's server, not a guess from here.
-      proposals: Guardianship.gated.listSent(site.slug, w.other_uri).map((p) => ({
-        feature: p.feature, value: !!p.value, created: p.created_at,
-        status: Guardianship.gated.sentStatus(p, Date.now()),
-      })),
-      // Alles wat voor dit kind gated is op EEN plek, met per gate het soort en
-      // de drempel (shaer-ahy.1). Losse knoppen lieten een guardian zelf
-      // uitzoeken wat er allemaal geldt; wat niet verstelbaar is stond nergens.
-      gates: Guardianship.queues.wardGates(site.slug, w.other_uri),
-    })),
-    offers: Guardianship.offersCollection(`${me}/queues/offers`, site.slug, me).orderedItems,
-    // Running lapses (3.6.3) this guardian or its local wards are party to.
-    lapses: Guardianship.availability.lapseQueueItems(site.slug, me, Date.now()),
-    // Gated-setting proposals another guardian opened on a ward we share
-    // (5.6), forwarded here by the ward's server. Without answering these the
-    // threshold is never met and the proposal simply expires.
-    gatedReviews: Guardianship.gated.listGatedReviews(site.slug).map((r) => ({
-      id: r.id, ward: r.ward_uri, proposer: r.proposer, feature: r.feature, value: !!r.value,
-      // Wat er blijft hangen als dit doorgaat (shaer-nf9). Alleen bij OPENZETTEN:
-      // dichtzetten laat niets nieuws door en hoeft dus niet gewaarschuwd te
-      // worden -- een waarschuwing die overal staat wordt nergens gelezen.
-      consequence: r.value ? Guardianship.gated.gateConsequence(r.feature) : null,
-      // Maakt JOUW antwoord dit af (shaer-8vt)? De telling loopt op de server van
-      // het kind, dus dit is het enige wat we erover weten -- en zonder dat
-      // weet niemand dat hij de doorslag geeft.
-      decisive: r.decisive !== 0,
-    })),
-    help: helpItems,
-    strings: uiStrings(L),
-  };
-}
-
-
-// ── The PWA page ─────────────────────────────────────────────────────────
-router.get('/', requireAuth, (req, res) => {
-  const site = siteForUser(req);
-  const L = resolveLang(req);
-  if (!site) return res.status(404).send('No site for this account.');
-  const sites = db.prepare('SELECT slug, title FROM sites WHERE owner_id = ? ORDER BY id').all(req.session.user.id);
-  // This standalone PWA page is rendered directly (not through renderPage), so
-  // the CSP nonce must be injected here — otherwise strict-dynamic blocks
-  // guardian.js and the whole dashboard is dead (buttons do nothing).
-  res.render('pages/guardian', {
-    state: dashboardState(site, L),
-    sites,
-    lang: L,
-    t: (k, v) => i18nT(L, k, v),
-    cspNonce: res.locals.cspNonce,
-  }, (err, html) => {
-    if (err) { console.error('[guardian] render error', err); return res.status(500).send('Internal Server Error'); }
-    res.send(injectCspNonce(html, res.locals.cspNonce));
-  });
-});
-
-// ── JSON state for refreshes ─────────────────────────────────────────────
-/**
- * De staat van het paneel, desgewenst als LANGE POLL (Barts opdracht, 9-8).
- *
- * Zonder `wait` gedraagt de route zich exact zoals altijd. Met `wait` blijft het
- * antwoord hangen tot er iets gebeurt dat de guardian moet verwerken, of tot de
- * tijd om is -- dan een lege 304.
- *
- * EERST KIJKEN, DAN WACHTEN. Veranderde er iets tussen het vorige antwoord en
- * dit verzoek, dan is de merksteen nu al anders en gaat het antwoord METEEN de
- * deur uit. Zou je eerst gaan wachten, dan blijft nieuws dat net in dat gaatje
- * viel vijfentwintig seconden liggen -- en juist bij een hulpvraag is dat de
- * verkeerde vertraging.
- *
- * WAKKER OP ALLES. De guardianship-module zendt veertien soorten gebeurtenissen
- * uit en die wekken allemaal (wakeGuardian); daarnaast wekt de tijdlijn (onNews),
- * want de berichten van je wards staan in ditzelfde scherm.
- */
-router.get('/api/state', requireAuth, async (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const stuur = () => AP.sendMaybe304(req, res, dashboardState(site, resolveLang(req)), { contentType: 'application/json' });
-
-  const wachtS = Math.min(Math.max(parseInt(req.query.wait, 10) || 0, 0), 50);
-  const merk = req.headers['if-none-match'];
-  if (!wachtS || !merk) return stuur();
-
-  // Is er nu al iets anders? Dan niet wachten.
-  const nu = AP.etagFor(JSON.stringify(dashboardState(site, resolveLang(req))));
-  if (nu !== merk) return stuur();
-
-  await new Promise((klaar) => {
-    let af = false;
-    const eind = () => { if (af) return; af = true; clearTimeout(t); offG(); offN(); klaar(); };
-    const offG = AP.onGuardian(site.slug, eind);
-    const offN = AP.onNews(site.slug, eind);
-    const t = setTimeout(eind, wachtS * 1000);
-    // Hing de client op, dan houdt niemand dit antwoord meer vast.
-    res.on('close', eind);
-  });
-  if (res.writableEnded) return undefined;
-  return stuur();
-});
-
-// ── Meekijken (FEP-633c §5, interop-hoofdroute): a committed guardian FOLLOWS
-//    its wards, so their posts (incl. followers-only) are DELIVERED to the
-//    guardian's inbox → timeline. The follow is the mechanism; no new fetch.
-//    First contact also backfills the ward's recent PUBLIC posts as a cold
-//    start so the corner is not empty before delivery catches up.
-function ensureWardConnections(site) {
-  let wards;
-  try { wards = Guardianship.listWards(site.slug); } catch { return; }
-  for (const w of wards) {
-    const already = db.prepare('SELECT 1 FROM ap_following WHERE slug = ? AND actor_uri = ?')
-      .get(site.slug, w.other_uri);
-    if (already) continue;
-    // Follow (guardian's server auto-accepts today; §5.3 gating is a later fase).
-    AP.followActor(site, w.other_uri).catch(() => { /* retried by the queue */ });
-    // Cold start: pull recent public posts now so oma sees something at once.
-    AP.backfillFromOutbox(site.slug, w.other_uri).catch(() => { /* best-effort */ });
-  }
-}
-
-// ── The wards' corner: your wards' posts, read-only. No reply, no share; a
-//    guardian watches, it does not publish (Robins besluit).
-router.get('/api/feed', requireAuth, (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const L = resolveLang(req);
-  ensureWardConnections(site);
-  const wardUris = new Set(Guardianship.listWards(site.slug).map((w) => w.other_uri));
-  // Only show the wards you actually guard (the timeline can hold more).
-  const items = AP.getTimeline(site.slug, 60, 0)
-    .filter((p) => wardUris.has(p.author_uri))
-    .map((p) => ({
-      id: p.id,
-      author: p.author_handle || p.author_name || p.author_uri,
-      authorUri: p.author_uri,   // the grouping key: which child's panel this belongs in
-      authorName: p.author_name,
-      authorIcon: p.author_icon,
-      content: p.content,
-      url: p.url,
-      published: p.published || p.created_at,
-      when_text: formatDateTime(p.published || p.created_at),
-      cw: p.cw || null,
-      // Zelfde valkuil als in note-body.ejs: kapotte json gooit, maar geldige json
-      // van het verkeerde type niet. Zonder deze wacht neemt één vreemde note van
-      // een remote server het hele guardian-paneel mee, en dat is precies het
-      // scherm dat het moet doen als er iets aan de hand is.
-      media: (() => { try { const m = JSON.parse(p.media_json || '[]'); return Array.isArray(m) ? m : []; } catch { return []; } })(),
-      // Een post van je ward hoort er hetzelfde uit te zien als in de Krant en
-      // in Berichten: dezelfde partial, dus opmaak, media, quote-kaart en
-      // embed. Tot nu toe kreeg de PWA alleen kale content -- een guardian zag
-      // een lege regel waar een foto stond. `content` blijft ernaast staan voor
-      // een client die nog uit de cache draait.
-      body_html: renderNoteBody(p, L),
-    }));
-  res.json({ items, following: wardUris.size });
-});
-
-// ── Follow-gating (FEP-633c §5.3): pending follows on MY wards, for me to
-//    approve. Ward and guardian are co-located on the family Klonkt here, so
-//    the guardian reads its wards' pending follows locally.
-function wardSlugsOf(site) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  return Guardianship.listWards(site.slug)
-    .map((w) => (w.other_uri.startsWith(base) ? { slug: w.other_uri.split('/').pop(), uri: w.other_uri } : null))
-    .filter(Boolean);
-}
-
-router.get('/api/follow-requests', requireAuth, (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const items = [];
-  const host = (() => { try { return new URL(process.env.PUBLIC_BASE_URL || '').host; } catch { return ''; } })();
-  // wardUri is the grouping key for the per-ward panel: the handle is for
-  // reading, the URI is what identifies the child across both cases below.
-  // Local wards (guardian co-located): read the pending follows directly.
-  for (const w of wardSlugsOf(site)) {
-    for (const f of Guardianship.follows.listForWard(w.slug)) {
-      items.push({ id: f.id, direction: 'incoming', ward: `@${w.slug}@${host}`, wardUri: w.uri, follower: f.follower_handle || f.follower_name || f.follower_uri, followerIcon: f.follower_icon, remote: false, created: f.created_at });
-    }
-    // §5.3 andersom (shaer-p729): wat dit kind zelf heeft gevraagd. Stond hier
-    // niet, dus een guardian met een LOKALE ward zag uitgaande verzoeken in de
-    // PWA helemaal niet -- ze wachtten op iemand die er nooit naar keek.
-    for (const o of Guardianship.outgoing.listForWard(w.slug)) {
-      items.push({ id: o.id, direction: 'outgoing', ward: `@${w.slug}@${host}`, wardUri: w.uri, target: o.target_handle || o.target_uri, remote: false, created: o.created_at });
-    }
-  }
-  // Remote wards: the copies forwarded here as Offer(Follow) (cross-instance).
-  for (const rev of Guardianship.follows.listReviews(site.slug)) {
-    const wardName = (() => { try { const u = new URL(rev.ward_uri); return `@${u.pathname.split('/').pop()}@${u.host}`; } catch { return rev.ward_uri; } })();
-    // De richting stond in de tabel en werd hier weggelaten. Zonder haar leest
-    // een uitgaand verzoek als een inkomend: de follower IS dan de ward, dus de
-    // kaart zei "je kind wil je kind volgen" en het doel viel weg.
-    const uitgaand = rev.direction === 'outgoing';
-    items.push({
-      id: rev.id, direction: uitgaand ? 'outgoing' : 'incoming',
-      ward: wardName, wardUri: rev.ward_uri,
-      follower: uitgaand ? undefined : (rev.follower_handle || rev.follower_uri),
-      target: uitgaand ? (rev.target_handle || rev.target_uri) : undefined,
-      followerIcon: uitgaand ? undefined : rev.follower_icon,
-      remote: true, created: rev.created_at,
-    });
-  }
-  res.json({ items });
-});
-
-// Het logboek (§4.2): wat er is gebeurd, met de reden erbij. GEEN wachtrij --
-// hier staat niets dat om een antwoord vraagt, en daarom hoort het ingeklapt.
-// Het bestaat omdat een weigering anders alleen te merken was doordat er iets
-// uit een lijst verdween, en "het is weg" vertelt een ward niet waarom.
-router.get('/api/events', requireAuth, (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  res.json({ items: AP.listGuardianEvents(site.slug, 50) });
-});
-
-router.post('/api/follow/:id', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const me = AP.actorId(base, site.slug);
-  const decision = req.body?.decision === 'reject' ? 'reject' : 'approve';
-
-  // Remote ward: a forwarded copy. Send my Accept/Reject back to the ward,
-  // which tallies quorum and returns the Accept(Follow) to the follower.
-  const review = Guardianship.follows.getReview(site.slug, req.params.id);
-  if (review) {
-    try { await AP.sendFollowDecision(site, review, decision); }
-    catch { return res.status(502).json({ error: 'delivery' }); }
-    Guardianship.follows.removeReview(site.slug, req.params.id);
-    return res.json({ ok: true, outcome: decision === 'reject' ? 'rejected' : 'sent' });
-  }
-
-  // Local ward: decide directly (quorum on this instance).
-  const pending = Guardianship.follows.getPending(req.params.id);
-  if (!pending) return res.status(404).json({ error: 'gone' });
-  const allGuardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
-  if (!allGuardians.includes(me)) return res.status(403).json({ error: 'not_a_guardian' });
-  // Acting from the dashboard is an answer (3.6), and the quorum runs over
-  // the available set (3.5): both applied here, the same as over the wire.
-  Guardianship.availability.oneAnswer(me, Date.now());
-  const guardians = Guardianship.availability.availableSet(pending.ward_slug, allGuardians, Date.now());
-  const r = Guardianship.follows.decide(pending.id, me, decision, guardians);
-  try {
-    if (r.outcome === 'approved') { await AP.acceptGatedFollow(r.follow); Guardianship.follows.remove(r.follow.id); }
-    else if (r.outcome === 'rejected') { await AP.rejectGatedFollow(r.follow); Guardianship.follows.remove(r.follow.id); }
-  } catch (e) { return res.status(502).json({ error: 'delivery', outcome: r.outcome }); }
-  res.json({ ok: true, outcome: r.outcome });
-});
-
-// ── §5.3, the other direction (shaer-p729): the ward wants to follow SOMEONE,
-//    and the guardians decide. Same quorum arithmetic and the same availability
-//    rules as the inbound gate above; only the question is turned around, which
-//    is why it gets its own endpoint rather than a flag on that one.
-router.post('/api/outgoing-follow/:id', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const me = AP.actorId(base, site.slug);
-  const decision = req.body?.decision === 'reject' ? 'reject' : 'approve';
-
-  const pending = Guardianship.outgoing.getPending(req.params.id);
-  if (!pending) return res.status(404).json({ error: 'gone' });
-  const allGuardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
-  if (!allGuardians.includes(me)) return res.status(403).json({ error: 'not_a_guardian' });
-  Guardianship.availability.oneAnswer(me, Date.now());
-  const guardians = Guardianship.availability.availableSet(pending.ward_slug, allGuardians, Date.now());
-  const r = Guardianship.outgoing.decide(pending.id, me, decision, guardians);
-  try {
-    // Only on approval does anything leave the building. A refusal is a local
-    // fact: the follow was never sent, so there is nothing out there to undo
-    // and nobody to inform that a child asked about them.
-    if (r.outcome === 'approved') await AP.performApprovedFollow(r.follow);
-  } catch { return res.status(502).json({ error: 'delivery', outcome: r.outcome }); }
-  res.json({ ok: true, outcome: r.outcome });
-});
-
-// ── Wave (FEP-633c §5, shaer:wave): a gentle "thinking of you" from a
-//    guardian to a ward. A private direct note, never a feed post. Warmth
-//    without publishing (Robins besluit).
-router.post('/api/wave', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const wardUri = String(req.body?.ward || '').trim();
-  // Only wave at a ward you actually guard.
-  const isWard = Guardianship.listWards(site.slug).some((w) => w.other_uri === wardUri);
-  if (!wardUri || !isWard) return res.status(403).json({ error: 'not_your_ward' });
-  const text = String(req.body?.text || '').trim().slice(0, 200) || '👋 thinking of you';
-  const r = await AP.deliverDirectNote(site, { recipients: [wardUri], text, wave: true }).catch(() => null);
-  if (!r) return res.status(502).json({ error: 'delivery' });
-  res.json({ ok: true, delivered: r.delivered });
-});
-
-// ── Een hulpvraag oppikken of afsluiten (shaer-lgo) ───────────────
-// Gaat naar de WARD en naar de MEDE-GUARDIANS. De ward hoort te weten dat er
-// iemand komt -- dat is de helft van de gerustheid -- en de anderen dat het
-// loopt, zodat niemand denkt dat de ander het al doet.
-//
-// OPPIKKEN mag stapelen: twee mensen die tegelijk reageren is geen probleem.
-// AFSLUITEN kent geen terugdraai; leeft de vraag nog, dan wordt hij opnieuw
-// gesteld. De stevige bevestiging zit in de client, net als bij het loslaten van
-// een ward: nooit een window.confirm.
-router.post('/api/help/:kind', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const kind = req.params.kind === 'handled' ? 'handled' : 'pickup';
-  const noteUri = String(req.body?.note || '').trim();
-  const wardUri = String(req.body?.ward || '').trim();
-  if (!noteUri || !/^https?:\/\//i.test(noteUri)) return res.status(400).json({ error: 'no_note' });
-  // Alleen over een hulpvraag van een kind dat je echt bewaakt.
-  const isWard = Guardianship.listWards(site.slug).some((w) => w.other_uri === wardUri);
-  if (!isWard) return res.status(403).json({ error: 'not_your_ward' });
-
-  const me = AP.actorId((process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''), site.slug);
-  // Onze eigen kopie meteen, zonder op bezorging te wachten: het scherm van
-  // degene die klikt hoort niet te liegen omdat een andere server traag is.
-  // MET onze eigen handle. Die stond hier op null, en "door wie" was juist de
-  // hele vraag van deze bead: een binnengekomen markering draagt de handle van
-  // de afzender wel, dus onze EIGEN rij was de enige zonder naam. Op het scherm
-  // viel dat terug op de kale URI.
-  Guardianship.help.record(noteUri, me, kind, AP.deriveHandle(me));
-
-  // DE MEDE-GUARDIANS, en dit ging mis (shaer-lgo, gevonden 11-8 met @mee).
-  //
-  // Hier stond listGuardians(wardUri.replace(/.*\/ap\/users\//, '')): de staart
-  // van de URI als slug. listGuardians kent alleen relaties van LOKALE sites,
-  // dus voor een ward elders leverde dat altijd een lege lijst -- en juist die
-  // ward is het hele punt, want een ward op je eigen instance heeft geen
-  // federatie nodig. De markering ging dus alleen naar het kind en nooit naar
-  // de andere guardian. Precies de faalstand waar deze bead voor bestaat:
-  // iedereen denkt dat de ander het oppakt.
-  //
-  // Erger nog: had er toevallig een lokale site met die naam bestaan, dan
-  // waren het DIENS guardians geweest.
-  //
-  // existingGuardiansOf kende de goede weg al -- lokaal opzoeken, en anders
-  // shaer:guardians uit de actor van de ward. Die stond alleen niet aan deze
-  // route vast.
-  const anderen = await Guardianship.existingGuardiansOf(wardUri).catch(() => []);
-  const ontvangers = [wardUri, ...anderen].filter((u) => u && u !== me);
-  const r = await AP.deliverDirectNote(site, {
-    recipients: ontvangers,
-    text: kind === 'handled' ? 'Deze hulpvraag is afgehandeld.' : 'Ik kijk hiernaar.',
-    helpMark: { kind, noteUri },
-  }).catch(() => null);
-  // Bezorging kan mislukken; de eigen staat staat er dan toch. Dat melden we,
-  // want "verstuurd" zeggen terwijl het niet aankwam is hier het ergste soort
-  // stilte.
-  res.json({ ok: true, delivered: r ? r.delivered : 0, recipients: ontvangers.length });
-});
-
-// ── Adopt a ward: handle → resolve → C2S Offer through the same pipeline
-//    the Shaer apps use (one path, one behavior).
-router.post('/adopt', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const handle = String(req.body?.handle || '').trim();
-  if (!handle) return res.status(400).json({ error: 'empty_handle' });
-  const wardUri = /^https?:\/\//i.test(handle) ? handle : await AP.webfingerResolve(handle).catch(() => null);
-  if (!wardUri) return res.status(404).json({ error: 'not_found' });   // the handle does not resolve to an account
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const me = AP.actorId(base, site.slug);
-  const r = await AP.ingestOutboxActivity(site, req.session.user, {
-    type: 'Offer',
-    object: { type: 'Relationship', subject: wardUri, relationship: 'shaer:Guardian', object: me },
-  });
-  // 403/400 = a real refusal (e.g. you are a ward yourself); anything else the
-  // offer is recorded and delivery is retried in the background.
-  if (!r || (r.status >= 400 && r.status !== 502)) return res.status(r?.status || 500).json({ error: r?.error || 'offer_failed' });
-  res.json({ ok: true, ward: wardUri, delivered: r.delivered !== false });
-});
-
-// ── Answer an offer (co-guardian accept/reject, or the candidate's final
-//    "complete"). All three are a C2S Accept/Reject on the offer id; the
-//    handshake module decides when it commits (§3.1).
-// ── Step away (FEP-633c 3.6.1): the guardian declares itself unavailable ──
-// One direct note with shaer:away and an endTime to every ward, the same path
-// Shaer takes over C2S, and the only path: a ward on this instance receives
-// that note through the loopback and applies the absence in its own inbox
-// handler, exactly as a ward elsewhere does. This route used to write the
-// local wards itself as well, which meant the wire version could break without
-// anyone here noticing.
-router.post('/api/away', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const days = Math.min(365, Math.max(1, parseInt(req.body?.days, 10) || 0));
-  if (!days) return res.status(400).json({ error: 'away_needs_an_end' });
-  const wards = Guardianship.listWards(site.slug).map((w) => w.other_uri);
-  if (!wards.length) return res.status(409).json({ error: 'no_wards' });
-  const until = Date.now() + days * 24 * 3600 * 1000;
-  const L = resolveLang(req);
-  const text = i18nT(L, 'guardian.away_msg', { date: new Date(until).toLocaleDateString('nl-NL') });
-  const r = await AP.deliverDirectNote(site, { recipients: wards, text, awayUntil: until }).catch(() => null);
-  if (!(r && r.id)) return res.status(502).json({ error: 'away_failed' });
-  res.json({ ok: true, until });
-});
-
-// ── Propose a lapse (FEP-633c 3.6.3) against a dormant co-guardian ────────
-// The same C2S pipeline the Shaer apps would use: an Offer of shaer:Lapse.
-// A local ward opens directly; a remote ward gets the proposal delivered,
-// because the ward's server is the one that tallies and enforces.
-router.post('/api/lapse', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const ward = String(req.body?.ward || '').trim();
-  const target = String(req.body?.target || '').trim();
-  if (!ward || !target) return res.status(400).json({ error: 'missing_ward_or_target' });
-  if (!Guardianship.listWards(site.slug).some((w) => w.other_uri === ward)) {
-    return res.status(403).json({ error: 'not_my_ward' });
-  }
-  const r = await AP.ingestOutboxActivity(site, req.session.user, {
-    type: 'Offer', object: { type: 'shaer:Lapse', 'shaer:ward': ward, object: target },
-  });
-  if (!r || r.status >= 400) return res.status(r?.status || 500).json({ error: r?.error || 'lapse_failed' });
-  res.json({ ok: true, lapse: r.id });
-});
-
-// ── Answer a forwarded gated-setting proposal (FEP-633c 5.6) ─────────────
-// The decision belongs to the ward's server, so the answer travels there as an
-// Accept/Reject on the offer id, exactly like a gated follow's decision.
-router.post('/api/gated/:id', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const review = Guardianship.gated.getGatedReview(site.slug, req.params.id);
-  if (!review) return res.status(404).json({ error: 'gone' });
-  const agree = req.body?.answer !== 'reject';
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const me = AP.actorId(base, site.slug);
-  const activity = {
-    id: `${me}#gated-${Date.now().toString(36)}`,
-    type: agree ? 'Accept' : 'Reject', actor: me, to: [review.ward_uri], object: review.id,
-  };
-  try { await AP.deliverToActor(site, review.ward_uri, activity); }
-  catch { return res.status(502).json({ error: 'delivery' }); }
-  Guardianship.gated.removeGatedReview(site.slug, review.id);
-  res.json({ ok: true, answer: agree ? 'accept' : 'reject' });
-});
-
-router.post('/offer', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const offerId = String(req.body?.offer || '').trim();
-  const answer = req.body?.answer === 'reject' ? 'Reject' : 'Accept';
-  if (!offerId) return res.status(400).json({ error: 'empty_offer' });
-  const r = await AP.ingestOutboxActivity(site, req.session.user, { type: answer, object: offerId });
-  if (!r || r.status >= 400) return res.status(r?.status || 500).json({ error: r?.error || 'answer_failed' });
-  res.json({ ok: true, committed: !!r.committed, readyToCommit: !!r.readyToCommit });
-});
-
-// ── PWA assets served no-cache, so an update is never masked by the 1-year
-//    /assets cache or a stuck install (that was the whole "nothing works after
-//    a deploy" bug). Small files; the browser revalidates and gets a 304 when
-//    unchanged, the fresh file when changed.
-function pwaAsset(rel, type) {
-  return (req, res) => {
-    res.set('Cache-Control', 'no-cache');
-    res.type(type);
-    res.sendFile(path.join(__dir, '..', 'assets', rel));
-  };
-}
-router.get('/app.js', pwaAsset('js/guardian.js', 'application/javascript'));
-router.get('/app.css', pwaAsset('css/guardian.css', 'text/css'));
-
-// ── Manage: release a committed ward (local Undo; federation is Fase 4). ──
-/**
- * What actually happens if this guardian releases this ward?
- *
- * Releasing is not one action but two very different ones, and the difference
- * is the number of guardians the child has left (FEP-633c):
- *   - more than one → §3.3, you step down and the child stays a ward;
- *   - you are the last → §3.4, that is emancipation, and the FEP is explicit
- *     that no single guardian decides it alone (three consenting adults, or a
- *     majority plus two witnesses).
- * On top of that, today's release is LOCAL: the Undo is not federated yet
- * (relations.js, fase 4), so the ward's server keeps listing this guardian.
- * A guardian pressing the button would otherwise believe the child is released.
- *
- * Answered on demand rather than in the dashboard state: for a ward we do not
- * host this reaches out to that ward's server, and nobody should pay for that
- * on every refresh.
- */
-router.get('/wards/release-check', requireAuth, async (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const uri = String(req.query.uri || '').trim();
-  if (!uri) return res.status(400).json({ error: 'empty_uri' });
-  if (!Guardianship.listWards(site.slug).some((w) => w.other_uri === uri)) {
-    return res.status(403).json({ error: 'not_my_ward' });
-  }
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const local = !!base && uri.startsWith(`${base}/`);
-  let guardians = null;   // null = we could not find out; say so rather than guess
-  if (local) {
-    const slug = uri.replace(/\/+$/, '').split('/').pop();
-    try { guardians = Guardianship.listGuardians(slug).length; } catch { /* stays null */ }
-  } else {
-    const doc = await AP.fetchActor(uri).catch(() => null);
-    const g = doc && doc['shaer:guardians'];
-    if (Array.isArray(g)) guardians = g.length;
-    else if (typeof g === 'string') guardians = 1;
-    else if (g && Array.isArray(g.items)) guardians = g.items.length;
-    else if (doc) guardians = 0;   // the actor answered and names no guardians
-  }
-  res.json({
-    guardians,
-    last: guardians === null ? null : guardians <= 1,
-    local,
-  });
-});
-
-// ── The fellow guardians of a ward, wherever it lives ─────────────────────
-// A guardian looking at a ward's panel should see who else holds a seat: that
-// is the child's safety net, and "dit kind woont op een andere server" is not
-// an answer. For a local ward the availability rides along (we do that
-// bookkeeping). For a remote ward we read the PUBLIC membership from its
-// actor document (shaer:guardians, §2.1) and nothing more: availability is
-// the ward's server's private ledger (§3.6.1) and stays there. Fetched on
-// panel-open rather than into the dashboard, so one slow remote server does
-// not hold the whole screen hostage.
-router.get('/wards/guardians', requireAuth, async (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const uri = String(req.query.uri || '').trim();
-  if (!Guardianship.listWards(site.slug).some((w) => w.other_uri === uri)) {
-    return res.status(403).json({ error: 'not_my_ward' });
-  }
-  const local = Guardianship.queues.wardGuardianStatuses(uri);
-  if (local) return res.json({ local: true, guardians: local });
-  const doc = await AP.fetchActor(uri).catch(() => null);
-  let g = doc && doc['shaer:guardians'];
-  if (g && Array.isArray(g.items)) g = g.items;             // a Collection
-  const guardians = (Array.isArray(g) ? g : (typeof g === 'string' ? [g] : []))
-    .filter((x) => typeof x === 'string')
-    .map((u) => {
-      try { const p = new URL(u); return { uri: u, handle: `@${p.pathname.replace(/\/+$/, '').split('/').pop()}@${p.host}` }; }
-      catch { return { uri: u, handle: u }; }
-    });
-  res.json({ local: false, guardians });
-});
-
-router.post('/wards/remove', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const uri = String(req.body?.uri || '').trim();
-  if (!uri) return res.status(400).json({ error: 'empty_uri' });
-  // Ending a guardianship is an Undo of the Relationship that travels to the
-  // ward and the other guardians (§3.2), not a local delete. Same call the
-  // Guardian apps reach over C2S, so the two cannot drift apart.
-  const r = await Guardianship.endGuardianship(site, uri);
-  if (r.status >= 400) return res.status(r.status).json({ error: r.error });
-  res.json({ ok: true, delivered: r.delivered, guardiansLeft: r.guardiansLeft });
-});
-
-/**
- * The external-embeds setting of a ward we host: true/false when a guardian has
- * decided, null when it is still on auto (which means off for a ward) or when
- * the ward lives elsewhere and the setting is not ours to show.
- */
-function wardEmbedSetting(uri) { return wardGateSetting(uri, 'external_embeds'); }
-/** The playback gate of a ward we host (5.6): the heavier sibling. */
-function wardPlaybackSetting(uri) { return wardGateSetting(uri, 'external_playback'); }
-
-function wardGateSetting(uri, column) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !String(uri || '').startsWith(`${base}/`)) return null;
-  const slug = String(uri).trim().replace(/\/+$/, '').split('/').pop();
-  const row = slug ? db.prepare(`SELECT ${column === 'external_playback' ? 'external_playback' : 'external_embeds'} AS v FROM sites WHERE slug = ?`).get(slug) : null;
-  if (!row) return null;
-  return row.v === null || row.v === undefined ? false : row.v === 1;
-}
-
-// ── Gated feature: may this ward see external (non-fediverse) embeds? ──
-// The first real gated setting (FEP-633c §5-style). The gate itself is applied
-// server-side when the feed is serialised, so this endpoint is the only way it
-// can move, and only a committed guardian of THAT ward may move it.
-router.post('/wards/embeds', requireAuth, express.json({ limit: '4kb' }), (req, res) => {
-  // Niet meer alleen embeds/playback: elke gate uit de catalogus met een kolom
-  // is voorstelbaar (8-8, "maak ze allemaal functioneel"). De oude regel
-  // HERSCHREEF een onbekende feature stilletjes naar externalEmbeds -- een
-  // voorstel voor de ene poort dat op de andere landt is precies het soort
-  // fout dat een guardian nooit mag overkomen. Onbekend wordt nu geweigerd.
-  const feature = String(req.body?.feature || 'shaer:externalEmbeds');
-  if (!Guardianship.gated.featureColumn(feature)) return res.status(400).json({ error: 'unknown_feature' });
-  req.body = { ...req.body, feature };
-  return proposeGated(req, res);
-});
-function proposeGated(req, res) {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  // De hele afweging staat in AP.proposeGate, zodat de apps langs dezelfde weg
-  // kunnen voorstellen (shaer-8ru). Deze route is nog maar de PWA-deur ernaartoe.
-  const uit = AP.proposeGate(site, req.body?.uri, req.body?.feature, req.body?.allow === true);
-  const { status, ...rest } = uit;
-  return res.status(status === 200 ? 200 : status).json(rest);
-}
-
-// ── The installable identity: own scope so the Guardian corner installs as
-//    its own app next to the site PWA.
-router.get('/manifest.webmanifest', (req, res) => {
-  const site = res.locals.site;
-  res.set('Cache-Control', 'no-cache');
-  res.json({
-    id: `klonkt-guardian-${site?.slug || 'guardian'}`,
-    name: 'Klonkt Guardian',
-    short_name: 'Guardian',
-    description: 'Ward management and help requests for guardians.',
-    scope: '/guardian/',
-    start_url: '/guardian?source=pwa',
-    display: 'standalone',
-    display_override: ['standalone', 'minimal-ui'],
-    orientation: 'any',
-    background_color: '#141a24',
-    theme_color: '#ff6b35',
-    lang: site?.language || 'nl',
-    icons: [
-      { src: '/guardian/icon.svg', sizes: 'any', type: 'image/svg+xml' },
-    ],
-  });
-});
-
-// The buoy mark, in the guardian accent (mirrors the site favicon pattern).
-router.get('/icon.svg', (req, res) => {
-  const svg = `<?xml version="1.0" encoding="UTF-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
-  <rect width="64" height="64" rx="14" fill="#ff6b35"/>
-  <text x="50%" y="50%" dy="0.35em" text-anchor="middle" font-size="36">&#128735;</text>
-</svg>`;
-  res.set('Content-Type', 'image/svg+xml');
-  res.set('Cache-Control', 'public, max-age=86400');
-  res.send(svg);
-});
-
-// Losse guardian-accounts (guardian-lite: /invite + /join, user + site met
-// guardian_only=1) zijn verwijderd op 31-7-2026. Een instance is een eigenaar;
-// zo'n account was de laatste multi-user-rest en zette bovendien andermans
-// wachtwoordhash, sessie en PRIVATE actor-sleutel in jouw database, wat een
-// verhuizing (shaer-qw6q) onmogelijk netjes maakte. Een guardian hoort een
-// eigen Klonkt te hebben; de adoptie loopt dan gewoon over de federatie.
-
-export default router;
Index: src/routes/lang.js
===================================================================
--- src/routes/lang.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,31 +1,0 @@
-// Visitor language choice: /lang/:code sets the interface language in the session
-// and redirects back to where you came from. (Content stays in the author's language.)
-import express from 'express';
-import { SUPPORTED } from '../services/i18n.js';
-import db from '../config/database.js';
-
-const router = express.Router();
-
-router.get('/lang/:code', (req, res) => {
-  const code = SUPPORTED.includes(req.params.code) ? req.params.code : 'nl';
-  if (req.session) req.session.lang = code;
-  // Logged in? Also save the choice on the account so it follows the user
-  // across devices/sessions (not just this session cookie).
-  if (req.session && req.session.user && req.session.user.id) {
-    try {
-      db.prepare('UPDATE users SET lang = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(code, req.session.user.id);
-      req.session.user.lang = code;
-    } catch { /* lang column missing on an old DB → session-only, no breakage */ }
-  }
-  // Safe back URL: internal path only (no open redirect).
-  let back = (typeof req.query.r === 'string') ? req.query.r : '';
-  if (!back.startsWith('/') || back.startsWith('//')) {
-    try {
-      const u = new URL(req.get('referer') || '');
-      back = u.pathname + (u.search || '');
-    } catch { back = '/'; }
-  }
-  res.redirect(back || '/');
-});
-
-export default router;
Index: src/routes/linkbio.js
===================================================================
--- src/routes/linkbio.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,61 +1,0 @@
-/**
- * Link-in-bio + click stats (premium feature #6).
- *
- *   GET /links          -> Linktree-style page with the site's profile_links
- *   GET /links/go/:i     -> counts the click (per url) and redirects to the external URL
- *
- * Reuses the existing sites.profile_links (JSON [{platform,url}]) + the
- * PLATFORMS icons/labels. Clicks are stored in link_clicks (see /admin/stats).
- * Open-redirect safe: /links/go/:i ONLY redirects to a url present in the
- * site's own profile_links. Hub: via /user/:slug/links.
- */
-
-import express from 'express';
-import db from '../config/database.js';
-import { renderPage } from '../middleware/render.js';
-import { premiumUnlocked } from '../services/PatreonService.js';
-import { PLATFORMS } from '../services/PlatformIcons.js';
-
-const router = express.Router();
-
-function parseLinks(site) {
-  if (!site || !site.profile_links) return [];
-  try { return JSON.parse(site.profile_links) || []; } catch { return []; }
-}
-
-router.get('/links', (req, res, next) => {
-  if (!premiumUnlocked()) return next();
-  const site = res.locals.site;
-  if (!site) return next();
-  const links = parseLinks(site).map((l, i) => {
-    const meta = PLATFORMS[l.platform] || {};
-    return { i, url: l.url, platform: l.platform, label: meta.label || l.platform, svg: meta.svg || '', brand: meta.brand || '' };
-  });
-  renderPage(req, res, 'pages/linkbio', {
-    pageTitle: (site.title || '') + ' — links',
-    bodyClass: 'on-linkbio',
-    lbLinks: links,
-  });
-});
-
-router.get('/links/go/:i', (req, res, next) => {
-  if (!premiumUnlocked()) return next();
-  const site = res.locals.site;
-  if (!site) return next();
-  const links = parseLinks(site);
-  const idx = parseInt(req.params.i, 10);
-  const link = (Number.isInteger(idx) && idx >= 0) ? links[idx] : null;
-  if (!link || !link.url) return next();
-  const url = String(link.url);
-  // Only external http(s) or mailto links (no open redirect / javascript:).
-  if (!/^https?:\/\//i.test(url) && !/^mailto:/i.test(url)) return res.status(400).send('Bad link');
-  try {
-    db.prepare(
-      `INSERT INTO link_clicks (site_id, url, clicks, updated_at) VALUES (?, ?, 1, CURRENT_TIMESTAMP)
-       ON CONFLICT(site_id, url) DO UPDATE SET clicks = clicks + 1, updated_at = CURRENT_TIMESTAMP`
-    ).run(site.id, url);
-  } catch { /* counting must never break the redirect */ }
-  res.redirect(302, url);
-});
-
-export default router;
Index: src/routes/newsletter.js
===================================================================
--- src/routes/newsletter.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,90 +1,0 @@
-/**
- * Newsletter — public side (premium feature #1).
- *
- *   GET  /nieuwsbrief                      -> sign-up form (premium; 404 otherwise)
- *   POST /nieuwsbrief                      -> subscribe (double opt-in if SMTP configured)
- *   GET  /nieuwsbrief/bevestigen/:token    -> confirm opt-in
- *   GET  /nieuwsbrief/uitschrijven/:token  -> unsubscribe (ALWAYS allowed)
- *
- * In hub mode this runs via /user/:slug/nieuwsbrief (resolveSite sets siteUrlBase).
- * Confirm/unsub links in the mail are absolute (PUBLIC_BASE_URL + siteUrlBase).
- */
-
-import express from 'express';
-import { renderPage } from '../middleware/render.js';
-import { premiumUnlocked } from '../services/PatreonService.js';
-import { mailerConfigured, sendMail } from '../config/mailer.js';
-import { addSubscriber, confirm, unsubscribe } from '../services/SubscriberService.js';
-
-const router = express.Router();
-
-function esc(s) {
-  return String(s || '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
-}
-function fullUrl(req, siteUrlBase, p) {
-  const base = (process.env.PUBLIC_BASE_URL || ('https://' + (req.get('host') || ''))).replace(/\/$/, '');
-  return base + (siteUrlBase || '') + p;
-}
-function show(req, res, state, extra = {}) {
-  renderPage(req, res, 'pages/newsletter', {
-    pageTitle: 'Nieuwsbrief' + (res.locals.site ? ' — ' + res.locals.site.title : ''),
-    bodyClass: 'on-newsletter',
-    nlState: state,
-    ...extra,
-  });
-}
-
-router.get('/nieuwsbrief', (req, res, next) => {
-  if (!premiumUnlocked()) return next();
-  if (!res.locals.site) return next();
-  const fan = req.session && req.session.user;
-  const prefill = (fan && fan.email && fan.email.includes('@')) ? fan.email : '';
-  show(req, res, 'form', { nlPrefill: prefill });
-});
-
-router.post('/nieuwsbrief', async (req, res, next) => {
-  if (!premiumUnlocked()) return next();
-  const site = res.locals.site;
-  if (!site) return next();
-  const email = (req.body.email || '').trim();
-  const doubleOptin = mailerConfigured();
-  const r = addSubscriber(site.id, email, 'widget', { doubleOptin });
-  if (!r.ok) return show(req, res, r.error === 'invalid_email' ? 'invalid' : 'error', { nlPrefill: email });
-
-  if (r.status === 'pending') {
-    // Double opt-in: send the confirmation email.
-    const link = fullUrl(req, res.locals.siteUrlBase, '/nieuwsbrief/bevestigen/' + r.token);
-    const unsub = fullUrl(req, res.locals.siteUrlBase, '/nieuwsbrief/uitschrijven/' + r.token);
-    try {
-      await sendMail({
-        to: email,
-        subject: 'Bevestig je inschrijving — ' + (site.title || 'nieuwsbrief'),
-        text: 'Bevestig je inschrijving op de nieuwsbrief van ' + (site.title || '') + ':\n' + link +
-              '\n\nNiet aangevraagd? Negeer deze mail. Uitschrijven: ' + unsub,
-        html: '<p>Bevestig je inschrijving op de nieuwsbrief van <strong>' + esc(site.title) + '</strong>:</p>' +
-              '<p><a href="' + link + '">Inschrijving bevestigen</a></p>' +
-              '<p style="color:#888;font-size:12px">Niet aangevraagd? Negeer deze mail. ' +
-              '<a href="' + unsub + '">Uitschrijven</a></p>',
-      });
-    } catch (e) {
-      return show(req, res, 'smtperror');
-    }
-    return show(req, res, 'check', { nlEmail: email });
-  }
-  return show(req, res, 'done', { nlEmail: email });
-});
-
-router.get('/nieuwsbrief/bevestigen/:token', (req, res, next) => {
-  if (!premiumUnlocked()) return next();
-  const ok = confirm(req.params.token);
-  show(req, res, ok ? 'confirmed' : 'badtoken');
-});
-
-// Unsubscribe is always allowed (even if the premium layer is later disabled): a
-// subscriber must always be able to opt out. Not premium-gated.
-router.get('/nieuwsbrief/uitschrijven/:token', (req, res) => {
-  const ok = unsubscribe(req.params.token);
-  show(req, res, ok ? 'unsubbed' : 'badtoken');
-});
-
-export default router;
Index: src/routes/oauth.js
===================================================================
--- src/routes/oauth.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,156 +1,0 @@
-/**
- * OAuth 2.0 routes for ActivityPub Client-to-Server (native/web clients).
- *
- *   POST /oauth/register            dynamic client registration (RFC 7591 subset)
- *   GET  /.well-known/oauth-authorization-server   server metadata (RFC 8414)
- *   GET  /oauth/authorize           consent screen (session-authenticated)
- *   POST /oauth/authorize           user grants → redirect back with ?code
- *   POST /oauth/token               code + PKCE verifier → bearer token
- *
- * Auth model: PUBLIC clients + PKCE only (see OAuthService). The consent screen
- * reuses Klonkt's normal login session; the token it mints is scoped to one
- * user + one of their sites.
- */
-import express from 'express';
-import db from '../config/database.js';
-import OAuth from '../services/OAuthService.js';
-import { requireAuth } from '../middleware/auth.js';
-import { renderPage } from '../middleware/render.js';
-import PermissionsService from '../services/PermissionsService.js';
-import { apEnabled } from '../services/SettingsService.js';
-
-const router = express.Router();
-router.use((req, res, next) => { if (!apEnabled()) return next('router'); next(); });
-
-const baseUrl = (req) => (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
-
-// Sites this user may post as (owner or co-admin). The consent screen lists these.
-function manageableSites(user) {
-  return db.prepare('SELECT id, slug, title, owner_id FROM sites ORDER BY created_at')
-    .all()
-    .filter((s) => PermissionsService.canAdminSite(user, s));
-}
-
-// Append query params to a redirect URI WITHOUT re-serializing it: native custom
-// schemes (com.shaer.app:/cb) get mangled by new URL().toString() (→ //cb/), and
-// RFC 6749 §4.1.2 says to append to the registered URI as-is. The URI is already
-// validated against the registered set before we ever call this.
-function redirectWith(redirectUri, params) {
-  const q = Object.entries(params)
-    .filter(([, v]) => v !== undefined && v !== null && v !== '')
-    .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
-    .join('&');
-  const sep = redirectUri.includes('?') ? '&' : '?';
-  return q ? `${redirectUri}${sep}${q}` : redirectUri;
-}
-
-// Hand control back to the client at redirect_uri + params. For a web client
-// (http/https) a plain 302 is right. For a NATIVE custom scheme
-// (com.klonkt.shaer:/oauth) a 302 is unreliable: mobile browsers routinely drop
-// a server redirect to a custom scheme (no user gesture). So we serve a tiny
-// interstitial that both auto-forwards AND offers a tap link — a tap is a user
-// gesture that launches the app on Android, and iOS's ASWebAuthenticationSession
-// intercepts either navigation. Same page for allow and deny (neutral copy).
-function finishRedirect(res, redirectUri, params) {
-  const target = redirectWith(redirectUri, params);
-  if (/^https?:\/\//i.test(redirectUri)) return res.redirect(target);
-  const attr = target.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
-  return res.type('html').send(`<!doctype html>
-<html lang="en"><head><meta charset="utf-8">
-<meta name="viewport" content="width=device-width,initial-scale=1">
-<meta http-equiv="refresh" content="0;url=${attr}">
-<title>Return to the app</title>
-<style>body{font-family:system-ui,-apple-system,sans-serif;background:#111;color:#eee;margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;text-align:center}
-.box{padding:1.5rem}p{color:#aaa;line-height:1.5}a.btn{display:inline-block;margin-top:1.2rem;padding:.85rem 1.7rem;border-radius:12px;background:#5A32E6;color:#fff;text-decoration:none;font-weight:700}</style>
-</head><body><div class="box">
-<p>Almost done. If the app doesn't open by itself:</p>
-<a class="btn" href="${attr}">Open the app</a>
-</div>
-<script>location.replace(${JSON.stringify(target)});</script>
-</body></html>`);
-}
-
-// Bounce back to the client with an OAuth error (RFC 6749 §4.1.2.1) when we have
-// a validated redirect_uri; otherwise render a plain error (open-redirect guard).
-function authError(res, redirectUri, state, error, desc) {
-  if (redirectUri) return finishRedirect(res, redirectUri, { error, error_description: desc, state });
-  return res.status(400).json({ error, error_description: desc });
-}
-
-// ── RFC 8414: server metadata ────────────────────────────────────────────
-router.get('/.well-known/oauth-authorization-server', (req, res) => {
-  const base = baseUrl(req);
-  res.type('application/json').json({
-    issuer: base,
-    authorization_endpoint: `${base}/oauth/authorize`,
-    token_endpoint: `${base}/oauth/token`,
-    registration_endpoint: `${base}/oauth/register`,
-    response_types_supported: ['code'],
-    grant_types_supported: ['authorization_code'],
-    code_challenge_methods_supported: ['S256'],
-    token_endpoint_auth_methods_supported: ['none'],
-    scopes_supported: ['c2s'],
-  });
-});
-
-// ── RFC 7591: dynamic client registration ────────────────────────────────
-router.post('/oauth/register', (req, res) => {
-  const out = OAuth.registerClient({ client_name: req.body.client_name, redirect_uris: req.body.redirect_uris });
-  if (out.error) return res.status(400).json(out);
-  return res.status(201).json(out);
-});
-
-// ── Authorization: consent screen ────────────────────────────────────────
-router.get('/oauth/authorize', requireAuth, (req, res) => {
-  const { client_id, redirect_uri, response_type, code_challenge, code_challenge_method, scope, state } = req.query;
-  const client = OAuth.getClient(client_id);
-  // Pre-redirect validation errors must NOT bounce to an unvalidated URI.
-  if (!client) return res.status(400).json({ error: 'invalid_client' });
-  if (!client.redirect_uris.includes(String(redirect_uri || ''))) return res.status(400).json({ error: 'invalid_request', error_description: 'redirect_uri not registered' });
-  if (response_type !== 'code') return authError(res, redirect_uri, state, 'unsupported_response_type');
-  if (code_challenge_method !== 'S256' || !code_challenge) return authError(res, redirect_uri, state, 'invalid_request', 'PKCE S256 required');
-
-  const sites = manageableSites(req.session.user);
-  if (!sites.length) return authError(res, redirect_uri, state, 'access_denied', 'no manageable sites for this account');
-
-  return renderPage(req, res, 'pages/oauth-consent', {
-    pageTitleKey: 'oauth.title', bodyClass: 'on-special',
-    client, sites, params: { client_id, redirect_uri, code_challenge, scope: scope || 'c2s', state: state || '' },
-  });
-});
-
-router.post('/oauth/authorize', requireAuth, (req, res) => {
-  const { client_id, redirect_uri, code_challenge, scope, state, site_slug, decision } = req.body;
-  const client = OAuth.getClient(client_id);
-  if (!client || !client.redirect_uris.includes(String(redirect_uri || ''))) {
-    return res.status(400).json({ error: 'invalid_request', error_description: 'client/redirect mismatch' });
-  }
-  if (decision !== 'allow') return authError(res, redirect_uri, state, 'access_denied');
-
-  const site = db.prepare('SELECT id, slug, owner_id FROM sites WHERE slug = ?').get(String(site_slug || ''));
-  if (!site || !PermissionsService.canAdminSite(req.session.user, site)) {
-    return authError(res, redirect_uri, state, 'access_denied', 'not allowed to post as this site');
-  }
-  const out = OAuth.createCode({
-    clientId: client_id, userId: req.session.user.id, siteSlug: site.slug,
-    redirectUri: redirect_uri, codeChallenge: code_challenge, scope,
-  });
-  if (out.error) return authError(res, redirect_uri, state, out.error, out.error_description);
-  return finishRedirect(res, redirect_uri, { code: out.code, state });
-});
-
-// ── Token exchange ───────────────────────────────────────────────────────
-router.post('/oauth/token', (req, res) => {
-  res.set('Cache-Control', 'no-store');
-  if (req.body.grant_type !== 'authorization_code') {
-    return res.status(400).json({ error: 'unsupported_grant_type' });
-  }
-  const out = OAuth.exchangeCode({
-    code: req.body.code, client_id: req.body.client_id,
-    redirect_uri: req.body.redirect_uri, code_verifier: req.body.code_verifier,
-  });
-  if (out.error) return res.status(400).json(out);
-  return res.json(out);
-});
-
-export default router;
Index: src/routes/og.js
===================================================================
--- src/routes/og.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,29 +1,0 @@
-/**
- * GET /og/:slug.png — themed Open Graph card for a site (1200x630 PNG).
- * Generated from the site's palette + accent (see OgImageService), cached.
- * Used as the default og:image so every site has a branded social preview.
- */
-import express from 'express';
-import db from '../config/database.js';
-import { ogImageFor } from '../services/OgImageService.js';
-
-const router = express.Router();
-
-router.get('/:slug.png', (req, res) => {
-  let site;
-  try {
-    site = db.prepare(
-      'SELECT slug, title, tagline, description, palette, accent, theme_override, og_theme FROM sites WHERE slug = ?'
-    ).get(req.params.slug);
-  } catch { /* db error → 404 below */ }
-  if (!site) return res.status(404).end();
-
-  const png = ogImageFor(site);
-  if (!png) return res.status(404).end(); // resvg unavailable → no card (graceful)
-
-  res.set('Content-Type', 'image/png');
-  res.set('Cache-Control', 'public, max-age=86400');
-  return res.send(png);
-});
-
-export default router;
Index: src/routes/openwebauth.js
===================================================================
--- src/routes/openwebauth.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,245 +1,0 @@
-/**
- * OpenWebAuth (FEP-61cf), de drie plekken waar de stroom ons raakt.
- *
- *   POST /owa/login   de bezoeker geeft zijn adres, wij sturen hem naar huis
- *   ALL  /owa/token   zijn server vraagt ondertekend om een token (stap 2/3)
- *   GET  /owa/logout  weer anoniem
- *
- * Plus de middleware onderaan, die op ELK verzoek naar `?owt=` en `?zid=` kijkt.
- *
- * Zie OpenWebAuthService voor de stroom als geheel en voor de drie aanvallen
- * die de FEP beschrijft.
- */
-import express from 'express';
-import * as AP from '../services/ActivityPubService.js';
-import * as OWA from '../services/OpenWebAuthService.js';
-import db from '../config/database.js';
-import { renderPage } from '../middleware/render.js';
-import { owaMagicLimiter } from '../middleware/rate-limit.js';
-
-const router = express.Router();
-
-/** Waar de bezoeker weer uitkomt. Altijd binnen onze eigen origin. */
-function returnUrlFor(req, path) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  // Alleen een pad, nooit iets wat de bezoeker meegaf als volledige URL: dat is
-  // hoe een aanmeldformulier een open redirect wordt.
-  let p = String(path || '/');
-  if (!p.startsWith('/') || p.startsWith('//')) p = '/';
-  return base + p;
-}
-
-/**
- * De handtekening staat hier in `Authorization`, niet in `Signature`.
- *
- * De FEP is er stellig over: "An OpenWebAuth signed request must have an
- * Authorization header starting with the word Signature". De rest van de
- * fediverse (en dus AP.verifyRequest) leest de `Signature`-header, zoals
- * Mastodon die stuurt. Twee gewoontes voor hetzelfde ding.
- *
- * Zonder deze vertaling zou elke ECHTE client -- Hubzilla, (streams), Forte --
- * hier een 401 krijgen terwijl hij alles goed deed, en zou pas de eerste
- * interop-proef dat aan het licht brengen.
- *
- * Geen mutatie van req: verifyRequest leest maar vier velden, dus we geven een
- * kopie mee. Zo blijft wat de rest van de keten ziet ongewijzigd.
- */
-function metSignatureHeader(req) {
-  const auth = String((req.headers && req.headers.authorization) || '');
-  if (req.headers && req.headers.signature) return req;      // al in de gewone vorm
-  if (!/^signature\s+/i.test(auth)) return req;              // niets te vertalen
-  return {
-    method: req.method,
-    originalUrl: req.originalUrl,
-    rawBody: req.rawBody,
-    headers: { ...req.headers, signature: auth.replace(/^signature\s+/i, '') },
-  };
-}
-
-/**
- * Stap 2/3: de home instance vraagt ondertekend om een token.
- *
- * GET EN POST, want de FEP zegt dat sommige home instances een POST met een
- * willekeurig lijf sturen.
- *
- * De verificatie is niet nieuw geschreven: AP.verifyRequest() doet dit al voor
- * de inbox, inclusief het vastpinnen van de sleutel op de herkomst van de actor
- * (anders host je een document met andermans id naast je eigen sleutel), een
- * replay-venster op Date, en een verplichte digest zodra er een lijf is. Eén
- * implementatie van "is deze aanvraag echt van wie hij zegt".
- */
-router.all('/owa/token', async (req, res) => {
-  let verified = null;
-  try { verified = await AP.verifyRequest(metSignatureHeader(req)); } catch { verified = null; }
-  if (!verified || !verified.id) {
-    return res.status(401).json({ success: false });
-  }
-  const pem = verified.publicKey && verified.publicKey.publicKeyPem;
-  if (!pem) return res.status(400).json({ success: false });
-
-  try {
-    const token = OWA.issueToken(verified.id);
-    // Versleuteld met ZIJN publieke sleutel: alleen de server die de bijbehorende
-    // prive-sleutel heeft kan hem lezen. Daarmee bewijst de teruggave dat de
-    // ondertekenaar ook echt die actor beheert, en niet alleen zijn document kent.
-    return res.json({ success: true, encrypted_token: OWA.encryptTokenFor(token, pem) });
-  } catch (e) {
-    console.warn('[owa] token uitgeven mislukte:', e && e.message);
-    return res.status(500).json({ success: false });
-  }
-});
-
-/**
- * Stap 1: de bezoeker typt zijn adres en wij sturen hem naar zijn eigen server.
- *
- * We slaan hier NIETS op over wie hij zegt te zijn. Dat is opzet: pas het
- * ingewisselde token bepaalt de identiteit (zie de impersonatie-aanval in de
- * FEP). Een `zid`/handle is niet meer dan een routeringshint.
- */
-router.post('/owa/login', async (req, res) => {
-  const terug = returnUrlFor(req, req.body && req.body.next);
-  const found = await OWA.discoverRedirectEndpoint(req.body && req.body.handle);
-  if (!found) {
-    const u = new URL(terug);
-    u.searchParams.set('owa_error', '1');
-    return res.redirect(u.toString());
-  }
-  return res.redirect(OWA.buildRedirect(found.endpoint, terug));
-});
-
-/**
- * /magic — de HOME-kant: onze gebruiker bewijst zich bij een andere site.
- *
- * Hier zijn de rollen omgedraaid. Een doelsite stuurt onze ingelogde gebruiker
- * hierheen; wij halen daar ondertekend een token op, ontsleutelen het met onze
- * eigen prive-sleutel en sturen hem terug met ?owt=. Dit is de enige plek waar
- * die sleutel nodig is -- en meteen de reden dat alleen een echte instance deze
- * kant kan spelen.
- *
- * WELKE IDENTITEIT? Op Klonkt is de fediverse-identiteit de SITE, niet het
- * account. Wie één site heeft gaat meteen door; wie er meer heeft kiest er een,
- * want ondertekenen en ontsleutelen kunnen alleen met een sleutel die hij ook
- * echt beheert.
- *
- * EN ER IS EEN TUSSENSCHERM, met opzet. De FEP waarschuwt onder "Information
- * leakage": OpenWebAuth geeft een STERKE identiteitsclaim af aan elke site die
- * erom vraagt, desnoods zonder dat je iets merkt. Deze omweg langs je eigen
- * server is het enige moment waarop je kunt zeggen: deze site niet.
- */
-function eigenSites(user) {
-  if (!user || !user.id) return [];
-  return db.prepare('SELECT slug, title FROM sites WHERE owner_id = ? ORDER BY is_primary DESC, created_at ASC')
-    .all(user.id);
-}
-
-/** De doelsite waar dit heen gaat, alleen om te TONEN. Beslissen doet bdest. */
-function doelHost(bdest) { try { return new URL(bdest).host; } catch { return ''; } }
-
-router.get('/magic', (req, res) => {
-  const bdest = OWA.fromBdest(req.query && req.query.bdest);
-  if (!bdest) return res.status(400).type('text/plain').send('bad bdest');
-
-  // Niet ingelogd? Dan eerst hier inloggen, en daarna terug naar dit scherm --
-  // met de bdest nog intact, anders is de hele stroom weg.
-  if (!(req.session && req.session.user)) {
-    const terug = '/magic?owa=1&bdest=' + encodeURIComponent(String(req.query.bdest));
-    return res.redirect('/auth/login?next=' + encodeURIComponent(terug));
-  }
-
-  const sites = eigenSites(req.session.user);
-  if (!sites.length) return res.status(403).type('text/plain').send('geen eigen actor om mee te tekenen');
-
-  return renderPage(req, res, 'pages/owa-consent', {
-    pageTitle: 'Aanmelden bij ' + doelHost(bdest.href),
-    bodyClass: 'on-special',
-    owaHost: doelHost(bdest.href),
-    owaBdest: String(req.query.bdest),
-    owaSites: sites,
-  });
-});
-
-/**
- * De gebruiker zei ja. Nu pas gaan we tekenen.
- *
- * De open-redirect-verdediging van deze kant zit in discoverTokenEndpoint(): de
- * FEP zegt dat we bij een mislukte ontdekking NIET naar bdest mogen doorsturen,
- * want dan is /magic het doorgeefluik. Vandaar dat elke fout hieronder een
- * foutpagina geeft en geen redirect.
- */
-router.post('/magic', owaMagicLimiter, async (req, res) => {
-  const bdest = OWA.fromBdest(req.body && req.body.bdest);
-  if (!bdest) return res.status(400).type('text/plain').send('bad bdest');
-  if (!(req.session && req.session.user)) return res.status(401).type('text/plain').send('niet ingelogd');
-
-  // De gekozen site moet er een van HEM zijn: anders tekent hij met andermans
-  // sleutel, en dat is precies het gat dat je hier niet wilt.
-  const sites = eigenSites(req.session.user);
-  const gekozen = sites.find((s) => s.slug === String(req.body.slug || '')) || sites[0];
-  if (!gekozen) return res.status(403).type('text/plain').send('geen eigen actor');
-
-  const endpoint = await OWA.discoverTokenEndpoint(bdest.href);
-  if (!endpoint) return res.status(502).type('text/plain').send('die site biedt geen OpenWebAuth aan');
-
-  try {
-    const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-    const keys = AP.getOrCreateKeys(gekozen.slug);
-    const keyId = AP.actorId(base, gekozen.slug) + '#main-key';
-    const versleuteld = await OWA.requestToken(endpoint, { keyId, privatePem: keys.private_pem });
-    if (!versleuteld) return res.status(502).type('text/plain').send('geen token gekregen');
-    const token = OWA.decryptToken(versleuteld, keys.private_pem);
-    if (!token) return res.status(502).type('text/plain').send('token onleesbaar');
-    const terug = new URL(bdest.href);
-    terug.searchParams.set('owt', token);
-    return res.redirect(terug.toString());
-  } catch (e) {
-    console.warn('[owa] /magic mislukte:', e && e.message);
-    return res.status(502).type('text/plain').send('aanmelden mislukte');
-  }
-});
-
-/** Weer anoniem. Raakt een eventuele lokale sessie niet aan. */
-router.get('/owa/logout', (req, res) => {
-  if (req.session) delete req.session.owa;
-  res.redirect(returnUrlFor(req, req.query && req.query.next));
-});
-
-/**
- * Op elk verzoek: is er een token ingewisseld, of wil iemand de stroom starten?
- *
- * `owt` BEPAALT de identiteit. `zid` start hooguit de stroom en wordt verder
- * genegeerd -- dat onderscheid IS de impersonatie-verdediging uit de FEP.
- */
-export function owaMiddleware(req, res, next) {
-  if (req.method !== 'GET' || !req.query) return next();
-
-  if (req.query.owt) {
-    let actor = null;
-    try { actor = OWA.redeemToken(req.query.owt); } catch { actor = null; }
-    if (actor) req.session.owa = { actor, at: Date.now() };
-    // Het token uit de URL halen: hij is toch al opgebruikt, en zo blijft hij
-    // niet in de geschiedenis, de titelbalk of een gedeelde link staan.
-    const u = new URL(req.originalUrl, process.env.PUBLIC_BASE_URL || 'https://localhost');
-    u.searchParams.delete('owt');
-    u.searchParams.delete('zid');
-    return res.redirect(u.pathname + (u.search || ''));
-  }
-
-  if (req.query.zid && !OWA.guestActor(req)) {
-    const u = new URL(req.originalUrl, process.env.PUBLIC_BASE_URL || 'https://localhost');
-    u.searchParams.delete('zid');
-    const terug = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '') + u.pathname + (u.search || '');
-    // Geen await in een middleware-keten die verder synchroon is: bij een fout
-    // gaat de bezoeker gewoon door naar de pagina, alleen zonder inlog.
-    OWA.discoverRedirectEndpoint(req.query.zid)
-      .then((found) => {
-        if (found) return res.redirect(OWA.buildRedirect(found.endpoint, terug));
-        return res.redirect(terug);
-      })
-      .catch(() => res.redirect(terug));
-    return;
-  }
-
-  return next();
-}
-
-export default router;
Index: src/routes/paid.js
===================================================================
--- src/routes/paid.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,137 +1,0 @@
-/**
- * Paid posts (klonkt-demo-aki) slice 3: the patron link + passkey flow.
- * Cookie-less throughout: the OAuth state and the WebAuthn challenge travel in
- * signed blobs (CryptoBox), never a session.
- *
- * GET  /paid/link?post=<slug>  -> redirect to Patreon authorize
- * GET  /paid/callback          -> verify patron, render the passkey page
- * POST /paid/register          -> verify the passkey, store the entitlement
- */
-import express from 'express';
-import db from '../config/database.js';
-import { renderPage } from '../middleware/render.js';
-import { premiumUnlocked } from '../services/PatreonService.js';
-import { signBlob, verifyBlob, cryptoBoxReady } from '../services/CryptoBox.js';
-import PaidPatreon from '../services/PaidPatreonService.js';
-import Passkey from '../services/PasskeyService.js';
-
-const router = express.Router();
-const AUTHORIZE = 'https://www.patreon.com/oauth2/authorize';
-
-const baseUrl = (req) => (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
-
-// The feature is only live when premium is on, secrets can be encrypted, and the
-// owner has connected a campaign.
-function ready(req, res) {
-  const site = res.locals.site;
-  if (!site) { res.status(404).end(); return null; }
-  if (!premiumUnlocked() || !cryptoBoxReady()) { res.status(404).end(); return null; }
-  const cfg = PaidPatreon.getOwnerConfig(site.id);
-  if (!cfg || !cfg.clientId || !cfg.campaignId) { res.status(404).end(); return null; }
-  return { site, cfg };
-}
-
-// Step 1: send the visitor to Patreon.
-router.get('/link', (req, res) => {
-  const r = ready(req, res); if (!r) return;
-  const slug = String(req.query.post || '').trim();
-  const post = slug ? db.prepare('SELECT slug, paid, paid_min_cents FROM posts WHERE site_id = ? AND slug = ?').get(r.site.id, slug) : null;
-  if (!post || !post.paid) return res.redirect((res.locals.siteUrlBase || '') + '/' + (slug || ''));
-  const cents = post.paid_min_cents || PaidPatreon.defaultMinCents(r.site.id);
-  const state = signBlob({ purpose: 'patron', siteId: r.site.id, cents, post: post.slug }, 900);
-  const url = `${AUTHORIZE}?response_type=code&client_id=${encodeURIComponent(r.cfg.clientId)}`
-    + `&redirect_uri=${encodeURIComponent(baseUrl(req) + '/paid/callback')}`
-    + `&scope=${encodeURIComponent('identity identity.memberships')}`
-    + `&state=${encodeURIComponent(state)}`;
-  res.redirect(url);
-});
-
-// Step 2: Patreon returns. Verify the patron; if a supporter at the right tier,
-// render the passkey-creation page.
-router.get('/callback', async (req, res) => {
-  const r = ready(req, res); if (!r) return;
-  const payload = verifyBlob(String(req.query.state || ''));
-  if (!payload || payload.purpose !== 'patron' || payload.siteId !== r.site.id) {
-    return renderPage(req, res, 'pages/paid-result', {
-      pageTitleKey: 'pres.t', bodyClass: 'on-post', ok: false, reason: 'expired',
-    });
-  }
-  const patronUrl = PaidPatreon.patreonUrl(r.site.id);
-  const code = String(req.query.code || '');
-  if (req.query.error || !code) {
-    return renderPage(req, res, 'pages/paid-result', { pageTitleKey: 'pres.t', bodyClass: 'on-post', ok: false, reason: 'declined', postSlug: payload.post, patronUrl });
-  }
-  const membership = await PaidPatreon.verifyPatron(r.site.id, code, baseUrl(req) + '/paid/callback').catch(() => null);
-  const cents = membership ? (membership.cents || 0) : 0;
-  const active = membership && membership.status === 'active_patron';
-  if (!active || cents < payload.cents) {
-    return renderPage(req, res, 'pages/paid-result', {
-      pageTitleKey: 'pres.t', bodyClass: 'on-post', ok: false,
-      reason: active ? 'tier' : 'notpatron', neededCents: payload.cents, haveCents: cents, postSlug: payload.post, patronUrl,
-    });
-  }
-  // Supporter at the right tier. Hand out registration options + a signed blob
-  // carrying the challenge and the proven cents; the passkey page returns both.
-  const options = await Passkey.registrationOptions(baseUrl(req), r.site.slug);
-  const blob = signBlob({ purpose: 'reg', siteId: r.site.id, cents, challenge: options.challenge }, 900);
-  renderPage(req, res, 'pages/paid-passkey', {
-    pageJs: 'paid-passkey',
-    pageTitleKey: 'ppk.t', bodyClass: 'on-post',
-    optionsJson: JSON.stringify(options), regBlob: blob, postSlug: payload.post,
-  });
-});
-
-// Step 3: verify the passkey and store the pseudonymous entitlement.
-router.post('/register', express.json({ limit: '64kb' }), async (req, res) => {
-  const r = ready(req, res); if (!r) return res.status(404).json({ error: 'unavailable' });
-  const { response, blob } = req.body || {};
-  const payload = verifyBlob(String(blob || ''));
-  if (!payload || payload.purpose !== 'reg' || payload.siteId !== r.site.id) {
-    return res.status(400).json({ error: 'bad_challenge' });
-  }
-  const cred = await Passkey.verifyRegistration(baseUrl(req), response, payload.challenge);
-  if (!cred) return res.status(400).json({ error: 'verify_failed' });
-  Passkey.storeEntitlement({
-    credentialId: cred.credentialId, siteId: r.site.id, publicKey: cred.publicKey,
-    counter: cred.counter, transports: cred.transports, minCents: payload.cents,
-  });
-  res.json({ ok: true });
-});
-
-// Step 4 (unlock): hand out authentication options for a passkey assertion.
-router.get('/challenge', async (req, res) => {
-  const r = ready(req, res); if (!r) return;
-  const slug = String(req.query.post || '').trim();
-  const post = slug ? db.prepare('SELECT slug, paid, paid_min_cents FROM posts WHERE site_id = ? AND slug = ?').get(r.site.id, slug) : null;
-  if (!post || !post.paid) return res.status(404).json({ error: 'not_paid' });
-  const cents = post.paid_min_cents || PaidPatreon.defaultMinCents(r.site.id);
-  const options = await Passkey.authenticationOptions(baseUrl(req));
-  const blob = signBlob({ purpose: 'auth', siteId: r.site.id, cents, post: post.slug, challenge: options.challenge }, 300);
-  res.json({ options, blob });
-});
-
-// Verify the assertion, check the entitlement, and return the full post body in
-// the SAME response. No unlock token becomes state (design decision).
-router.post('/unlock', express.json({ limit: '64kb' }), async (req, res) => {
-  const r = ready(req, res); if (!r) return res.status(404).json({ error: 'unavailable' });
-  const { response, blob } = req.body || {};
-  const payload = verifyBlob(String(blob || ''));
-  if (!payload || payload.purpose !== 'auth' || payload.siteId !== r.site.id) return res.status(400).json({ error: 'bad_challenge' });
-  const credId = response && response.id;
-  const ent = credId ? Passkey.getEntitlement(credId, r.site.id) : null;
-  if (!ent) return res.status(403).json({ error: 'no_entitlement' });      // unknown/expired passkey
-  if ((ent.min_cents || 0) < payload.cents) return res.status(403).json({ error: 'tier' });
-  const vr = await Passkey.verifyAssertion(baseUrl(req), response, payload.challenge, ent);
-  if (!vr) return res.status(400).json({ error: 'verify_failed' });
-  Passkey.bumpCounter(credId, vr.newCounter);
-  const post = db.prepare("SELECT * FROM posts WHERE site_id = ? AND slug = ? AND status = 'published'").get(r.site.id, String(payload.post || ''));
-  if (!post || !post.paid) return res.status(404).json({ error: 'gone' });
-  // Hand back a short-lived, single-post unlock capability. The client reloads
-  // the real post page with it (?u=), so the post renders through its normal
-  // template: correct layout, scoped styles, working audio. Not a cookie and
-  // not stored: a 120s signed blob that lives only in that one URL.
-  const token = signBlob({ purpose: 'unlocked', siteId: r.site.id, post: post.slug }, 120);
-  res.json({ ok: true, redirect: `${res.locals.siteUrlBase || ''}/${encodeURIComponent(post.slug)}?u=${encodeURIComponent(token)}` });
-});
-
-export default router;
Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/routes/posts.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -3,11 +3,9 @@
 import path from 'path';
 import fs from 'fs';
+import { fileURLToPath } from 'url';
 import multer from 'multer';
-import ejs from 'ejs';
 import db from '../config/database.js';
-import { POST_TYPES, KEUZE_TYPES } from '../config/post-types.js';
-import { requireAuth, requireSiteManager, isViewer } from '../middleware/auth.js';
+import { requireAuth } from '../middleware/auth.js';
 import { renderPage } from '../middleware/render.js';
-import { recordPageview, recordPostView } from '../services/StatsService.js';
 import PermissionsService from '../services/PermissionsService.js';
 import MarkdownService from '../services/MarkdownService.js';
@@ -15,46 +13,15 @@
 import AudioEmbedService from '../services/AudioEmbedService.js';
 import PlaylistService from '../services/PlaylistService.js';
-import { audioEnabled } from '../config/features.js';
-import { audioUrl } from '../services/AudioStreamService.js';
-import { toWebp } from '../services/ImageWebpService.js';
-import VideoCoverService from '../services/VideoCoverService.js';
-import ActivityPubService from '../services/ActivityPubService.js';
-import * as Guardianship from '../services/guardianship/index.js';
-import { premiumUnlocked } from '../services/PatreonService.js';
-import { defaultMinCents as paidDefaultMinCents, patreonUrl as paidPatronUrl } from '../services/PaidPatreonService.js';
-import { verifyBlob } from '../services/CryptoBox.js';
-import { postEntry } from '../services/PostAccessService.js';
-import * as OWA from '../services/OpenWebAuthService.js';
-import MusicMeta from '../services/MusicMeta.js';
-import { mediaDir } from '../config/paths.js';
-
-const POST_IMAGES_DIR = mediaDir('POST_IMAGES_PATH', 'post-images');
+import { signUrl } from '../services/AudioStreamService.js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const POST_IMAGES_DIR = path.resolve(
+  process.env.POST_IMAGES_PATH ||
+  path.join(__dirname, '..', '..', 'storage', 'media', 'post-images')
+);
 fs.mkdirSync(POST_IMAGES_DIR, { recursive: true });
 
 const ALLOWED_IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
 const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
-
-// Rich replies: media dropped/pasted into the reply editor. Images, audio and
-// video, stored as-is (no transcode; a reply attachment is not a track).
-const REPLY_MEDIA_DIR = mediaDir('REPLY_MEDIA_PATH', 'reply-media');
-fs.mkdirSync(REPLY_MEDIA_DIR, { recursive: true });
-const ALLOWED_REPLY_MEDIA_EXT = new Set([
-  '.jpg', '.jpeg', '.png', '.webp', '.gif',
-  '.mp3', '.m4a', '.ogg', '.opus', '.flac', '.wav',
-  '.mp4', '.webm', '.mov',
-]);
-const MAX_REPLY_MEDIA_BYTES = 32 * 1024 * 1024;
-const replyMediaUpload = multer({
-  storage: multer.diskStorage({
-    destination: (req, file, cb) => cb(null, REPLY_MEDIA_DIR),
-    filename: (req, file, cb) => cb(null, `${uuid()}${path.extname(file.originalname).toLowerCase()}`),
-  }),
-  limits: { fileSize: MAX_REPLY_MEDIA_BYTES },
-  fileFilter: (req, file, cb) => {
-    const ext = path.extname(file.originalname).toLowerCase();
-    if (!ALLOWED_REPLY_MEDIA_EXT.has(ext)) return cb(new Error('Media must be an image, audio or video file'));
-    cb(null, true);
-  },
-});
 
 const imageStorage = multer.diskStorage({
@@ -77,25 +44,5 @@
 });
 
-// Generates a unique slug within the site: 'title', 'title-2', 'title-3', …
-// A second post with the same title is NOT rejected ("already exists"),
-// but automatically gets a free suffix. exceptId = the post being updated
-// (allowed to keep its own slug).
-function uniqueSlug(siteId, base, exceptId = null) {
-  let candidate = base;
-  let n = 2;
-  for (;;) {
-    const row = exceptId
-      ? db.prepare('SELECT id FROM posts WHERE site_id = ? AND slug = ? AND id != ?').get(siteId, candidate, exceptId)
-      : db.prepare('SELECT id FROM posts WHERE site_id = ? AND slug = ?').get(siteId, candidate);
-    if (!row) return candidate;
-    candidate = `${base}-${n++}`;
-  }
-}
-
 const router = express.Router();
-
-// Feed page size for "Load more" (Solo, News, Messages, Cirkel). 72 is divisible
-// by 2/3/4 so every grid column count ends on a full row.
-const FEED_PAGE = 72;
 
 // ==================== UPLOAD IMAGE (cover or content) ====================
@@ -103,40 +50,9 @@
 // insert a markdown ![](url) into content.
 router.post('/posts/upload-image', requireAuth, (req, res) => {
-  imageUpload.single('image')(req, res, async (err) => {
+  imageUpload.single('image')(req, res, (err) => {
     if (err) return res.status(400).json({ error: err.message });
     if (!req.file) return res.status(400).json({ error: 'No file' });
-    const name = toWebp(req.file);
-    const url = '/media/post-images/' + name;
-    // An animated WebP cover → also make a muted loop MP4 (Safari plays it smoothly where the
-    // animated WebP is janky on iOS). Best-effort; on failure we just return the still image.
-    // The editor stores `video` in the hidden cover_video_url field for the cover.
-    let video = null;
-    try {
-      const src = path.join(POST_IMAGES_DIR, name);
-      if (VideoCoverService.isAnimatedWebp(src)) {
-        const r = await VideoCoverService.animatedWebpToVideo(src, POST_IMAGES_DIR, path.basename(name, path.extname(name)) + '-v');
-        if (r) video = '/media/post-images/' + path.basename(r.videoPath);
-      }
-    } catch { /* keep the still image */ }
-    res.json({ url, video, size: req.file.size, mime: req.file.mimetype });
-  });
-});
-
-// Rich replies: media for a reply (image/audio/video). Returns { url, mediaType, name }
-// exactly as the editor's attachments JSON wants it; deliverReply re-validates.
-router.post('/posts/upload-reply-media', requireSiteManager, (req, res) => {
-  replyMediaUpload.single('media')(req, res, (err) => {
-    if (err) return res.status(400).json({ error: err.message });
-    if (!req.file) return res.status(400).json({ error: 'No file' });
-    const mime = String(req.file.mimetype || '');
-    if (!/^(image|audio|video)\//.test(mime)) {
-      try { fs.unlinkSync(req.file.path); } catch { /* best effort */ }
-      return res.status(400).json({ error: 'Media must be an image, audio or video file' });
-    }
-    res.json({
-      url: '/media/reply-media/' + req.file.filename,
-      mediaType: mime,
-      name: String(req.file.originalname || '').slice(0, 120),
-    });
+    const url = '/media/post-images/' + req.file.filename;
+    res.json({ url, size: req.file.size, mime: req.file.mimetype });
   });
 });
@@ -145,12 +61,7 @@
   'auth', 'admin', 'login', 'register', 'logout',
   'archive', 'search', 'account', 'sites', 'comments',
-  'posts', 'media', 'audio', 'forum',
-  'tag', 'type', 'user', 'users', 'artiesten', 'leden', 'favorieten', 'feed.xml', 'atom.xml', 'sitemap.xml',
+  'posts', 'media', 'audio', 'prutter', 'forum',
+  'tag', 'type', 'users', 'feed.xml', 'atom.xml', 'sitemap.xml',
   'manifest.webmanifest', 'sw.js', 'favicon.ico', 'favicon.svg', 'assets',
-  'authorize_interaction', 'fediverse', 'news', 'following', 'notifications', 'blocking',
-  'paid', 'push', 'guardian',
-  // De meeslepende leesweergave. Gereserveerd
-  // omdat een bericht met deze slug de route anders zou overschaduwen.
-  'read',
 ]);
 
@@ -171,29 +82,4 @@
 }
 
-// Poll durations offered in the editor (seconds) — the Mastodon set (5m … 7d).
-const POLL_DURATIONS = new Set([300, 1800, 3600, 21600, 43200, 86400, 259200, 604800]);
-// Parse the editor's poll fields into the poll_json we store on the post (which
-// buildNote federates as an AS2 Question). Returns null when no valid poll (< 2
-// options or the poll checkbox is off). endTime is set from the chosen duration
-// (default 1 day) so the Scheduler can close it.
-function parsePollForm(body) {
-  if (!body || !body.poll_enabled) return null;
-  const raw = body.poll_option == null ? [] : (Array.isArray(body.poll_option) ? body.poll_option : [body.poll_option]);
-  const options = [];
-  const seen = new Set();
-  for (const o of raw) {
-    const name = String(o == null ? '' : o).trim().slice(0, 100);
-    if (!name) continue;
-    const key = name.toLowerCase();
-    if (seen.has(key)) continue; seen.add(key);
-    options.push({ name });
-    if (options.length >= 8) break;
-  }
-  if (options.length < 2) return null;
-  const dur = parseInt(body.poll_duration, 10);
-  const secs = POLL_DURATIONS.has(dur) ? dur : 86400;
-  return JSON.stringify({ multiple: !!body.poll_multiple, options, endTime: new Date(Date.now() + secs * 1000).toISOString(), closed: false });
-}
-
 // ==================== HOME (Posts list) ====================
 router.get('/', (req, res) => {
@@ -218,42 +104,19 @@
   `).all(site.id);
 
-  // Regular posts: anything with pinned = 0. Paged in blocks of 72 (Load more).
-  const append = req.query.append === '1';
-  const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
-  const rows = db.prepare(`
+  // Regular posts: anything with pinned = 0
+  const posts = db.prepare(`
     SELECT p.*, u.username as author_username
     FROM posts p JOIN users u ON p.author_id = u.id
     WHERE p.site_id = ? AND p.status = 'published' AND p.pinned = 0
     ORDER BY p.published_at DESC
-    LIMIT ? OFFSET ?
-  `).all(site.id, FEED_PAGE + 1, offset);
-  const hasMore = rows.length > FEED_PAGE;
-  const posts = rows.slice(0, FEED_PAGE);
-  const moreBase = res.locals.siteUrlBase || '';
-
-  if (append) {
-    return renderPage(req, res, 'partials/home-append', {
-      posts, hasMore, nextOffset: offset + FEED_PAGE, moreBase,
-      readerItems: readerItems(site, posts, req),
-    });
-  }
-
-  recordPageview(site.id, req);
-
-  // FEP-7628 slice 3: this account moved. A visitor who lands here deserves
-  // the same signpost the fediverse gets — one big link to the new address.
-  const movedTo = site.moved_to && /^https?:\/\//i.test(String(site.moved_to)) ? String(site.moved_to) : null;
+    LIMIT 30
+  `).all(site.id);
+
   renderPage(req, res, 'pages/home', {
     pinnedPosts,
     posts,
-    readerItems: readerItems(site, [...pinnedPosts, ...posts], req),
-    hasMore, nextOffset: offset + FEED_PAGE, moreBase,
-    movedTo,
-    movedToLabel: movedTo ? (ActivityPubService.actorDisplay(site.slug, movedTo).handle || movedTo) : null,
     pageTitle: site.title,
     socialDescr: site.description || site.tagline || '',
     bodyClass: 'on-home',
-    // mod/read.js: alleen nog de tik-op-een-bericht in de leesweergave.
-    pageJs: 'read tape',
   });
 });
@@ -268,6 +131,4 @@
 
   renderPage(req, res, 'pages/post-edit', {
-    // post-edit neemt de playlist-editor op.
-    pageJs: 'post-edit playlist-editor',
     post: {
       id: uuid(),
@@ -277,5 +138,4 @@
     },
     isNew: true,
-    keuzeTypes: KEUZE_TYPES,
     pageTitle: 'New post',
     bodyClass: 'on-special',
@@ -284,59 +144,4 @@
 
 // ==================== CREATE POST ====================
-// ── Per-post audio federation ──────────────────────────────────────────────
-// "Share audio on the fediverse" is a per-post choice in the editor, but the underlying
-// flag is per track (audio_tracks.fedi_open — it gates the file + drives the AS2 Audio
-// attachment). NB: the file gate is per file, so opening a track in one post makes its file
-// fetchable for every post that reuses it.
-// ONE-WAY: opening is permanent. Once the file has federated it's out there — re-gating
-// would be false security (remote copies keep the URL), so we never write fedi_open back to 0.
-function setAudioFediOpen(siteId, content, open) {
-  if (!open) return; // never close — see one-way note above
-  const c = content || '';
-  try {
-    for (const m of c.matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) db.prepare('UPDATE audio_tracks SET fedi_open = 1 WHERE id = ? AND site_id = ?').run(m[1], siteId);
-    for (const m of c.matchAll(/\[\[album:([^\]]+)\]\]/g)) db.prepare('UPDATE audio_tracks SET fedi_open = 1 WHERE site_id = ? AND album = ?').run(siteId, m[1].trim());
-    // playlists.id is a GLOBAL key, so the site filter has to sit on the tracks: without it a
-    // post on site A embedding site B's playlist would open B's files — permanently.
-    for (const m of c.matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) db.prepare('UPDATE audio_tracks SET fedi_open = 1 WHERE site_id = ? AND id IN (SELECT track_id FROM playlist_tracks WHERE playlist_id = ?)').run(siteId, m[1]);
-  } catch { /* non-fatal */ }
-}
-// True when the post references hosted audio AND all of it is currently fedi_open (drives the
-// editor checkbox's initial state).
-function postAudioFediOpen(siteId, content) {
-  const c = content || '';
-  if (!/\[\[(track|album|playlist):/i.test(c)) return false;
-  let total = 0, open = 0;
-  const tally = (r) => { if (r && r.media_id) { total++; if (r.fedi_open) open++; } };
-  try {
-    for (const m of c.matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) tally(db.prepare('SELECT fedi_open, media_id FROM audio_tracks WHERE id = ? AND site_id = ?').get(m[1], siteId));
-    for (const m of c.matchAll(/\[\[album:([^\]]+)\]\]/g)) for (const r of db.prepare('SELECT fedi_open, media_id FROM audio_tracks WHERE site_id = ? AND album = ? AND media_id IS NOT NULL').all(siteId, m[1].trim())) tally(r);
-    for (const m of c.matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) for (const r of db.prepare('SELECT t.fedi_open, t.media_id FROM playlist_tracks pt JOIN audio_tracks t ON t.id = pt.track_id WHERE pt.playlist_id = ? AND t.media_id IS NOT NULL').all(m[1])) tally(r);
-  } catch { /* non-fatal */ }
-  return total > 0 && open === total;
-}
-
-// Bake + cache a post's display HTML (ActivityPub `source` model): `content` stays the raw
-// source (used by the editor + re-rendering), content_rendered holds the linkified render the
-// page serves. Called after every create/edit. Non-fatal: the render route falls back to
-// baking on the fly if this ever fails.
-function cacheRenderedContent(postId, rawContent) {
-  const raw = rawContent || '';
-  // 1. Immediate + synchronous: bake #hashtags + URLs so the post renders enriched at once.
-  try {
-    db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?')
-      .run(ActivityPubService.bakePostContent(raw), postId);
-  } catch (e) { /* fallback bake in the render route keeps display correct */ }
-  // 2. Async: resolve @mentions (webfinger, once) and re-store, WITHOUT blocking the save
-  //    response — a moment later the post's @mentions are clickable too. A slow/dead remote
-  //    server can't stall the save; on failure the sync bake from step 1 stands.
-  ActivityPubService.bakePostContentWithMentions(raw)
-    .then((html) => {
-      try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(html, postId); }
-      catch (e) { /* keep the sync bake */ }
-    })
-    .catch(() => { /* keep the sync bake */ });
-}
-
 router.post('/posts/create', requireAuth, (req, res) => {
   const site = res.locals.site;
@@ -344,23 +149,6 @@
     return res.status(403).send('No permission');
   }
-  // Verhuisd = niet meer schrijven. Dit moet HIER staan en niet pas bij
-  // deliverCreate: die weigert alleen de bezorging, waarna de post gewoon in de
-  // database belandt met een object-URI op een adres dat je hebt opgezegd. Dan
-  // lijkt het gelukt, staat het er, en sterft het met het domein. Precies de
-  // halve toestand die dit slot moet voorkomen.
-  if (ActivityPubService.movedLock(site).locked) {
-    return res.status(409).send('Dit account is verhuisd naar ' + ActivityPubService.movedLock(site).movedTo
-      + '. Nieuwe berichten maak je daar. Wil je terug? Maak het verhuisadres leeg bij Uiterlijk.');
-  }
 
   const { title, slug, content, excerpt, status, pinned, cover_image_url, tags, noindex, type } = req.body;
-  const fanOnly = req.body.fan_only ? 1 : 0;
-  const paid = (premiumUnlocked() && req.body.paid) ? 1 : 0;   // paid posts (klonkt-demo-aki)
-  const paidEur = String(req.body.paid_min_eur || '').replace(',', '.').trim();
-  const paidMinCents = paid && paidEur ? Math.round(parseFloat(paidEur) * 100) : null;
-  const nsfw = req.body.nsfw ? 1 : 0;
-  const cw = (req.body.content_warning || '').trim().slice(0, 200);
-  const coverAlt = (req.body.cover_alt || '').trim().slice(0, 1500) || null; // cover alt text (a11y)
-  const language = /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(req.body.language || '') ? req.body.language : (res.locals.lang || null); // BCP-47 content language
 
   // Content arrives as user-authored HTML from the WYSIWYG editor — sanitize
@@ -370,5 +158,5 @@
 
   // Generate slug from title if empty
-  let finalSlug = (slug || title || '')
+  const finalSlug = (slug || title || '')
     .toLowerCase()
     .replace(/[^a-z0-9]+/g, '-')
@@ -376,45 +164,31 @@
 
   if (!finalSlug) return res.status(400).send('Title or slug required');
-  if (RESERVED_SLUGS.has(finalSlug)) finalSlug = `${finalSlug}-post`;
-
-  // Duplicate title/slug? Make it unique automatically (title-2, title-3, …) instead of rejecting.
-  finalSlug = uniqueSlug(site.id, finalSlug);
-
-  const finalType = POST_TYPES.has(type) ? type : 'post';
-  const pollJson = parsePollForm(req.body);   // AS2 Question definition, or null
+  if (RESERVED_SLUGS.has(finalSlug)) return res.status(400).send('That slug is reserved');
+
+  // Uniqueness check
+  const existing = db.prepare('SELECT id FROM posts WHERE site_id = ? AND slug = ?').get(site.id, finalSlug);
+  if (existing) return res.status(400).send('A post with that slug already exists');
+
+  const validTypes = new Set(['post', 'foto', 'video', 'audio']);
+  const finalType = validTypes.has(type) ? type : 'post';
   const postId = uuid();
   const now = new Date().toISOString();
-  let finalStatus = status || 'draft';
-  let publishedAt = finalStatus === 'published' ? now : null;
-  // Release planning: published + a future publish_at -> 'scheduled'
-  // (the Scheduler makes it live at that moment). Past/empty -> live immediately.
-  let publishAt = null;
-  const pa = Date.parse(req.body.publish_at || '');
-  if (req.body.schedule_enabled && finalStatus === 'published' && Number.isFinite(pa) && pa > Date.now()) {
-    finalStatus = 'scheduled';
-    publishAt = new Date(pa).toISOString();
-    publishedAt = null;
-  }
+  const finalStatus = status || 'draft';
+  const publishedAt = finalStatus === 'published' ? now : null;
 
   db.prepare(`
     INSERT INTO posts (
       id, site_id, slug, author_id, title, content, excerpt,
-      status, cover_image_url, cover_video_url, cover_alt, language, pinned, tags, type, noindex, fan_only, nsfw, content_warning, poll_json, publish_at,
+      status, cover_image_url, pinned, tags, type, noindex,
       created_at, updated_at, published_at
-    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
   `).run(
     postId, site.id, finalSlug, req.session.user.id,
     title || finalSlug, cleanContent, excerpt || '',
-    finalStatus, cover_image_url || null, (req.body.cover_video_url || null), coverAlt, language, parsePinnedRank(pinned),
+    finalStatus, cover_image_url || null, parsePinnedRank(pinned),
     JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)),
-    finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, pollJson, publishAt,
+    finalType, noindex ? 1 : 0,
     now, now, publishedAt
   );
-  cacheRenderedContent(postId, cleanContent); // bake display HTML (ActivityPub `source` model)
-  db.prepare('UPDATE posts SET paid = ?, paid_min_cents = ? WHERE id = ?').run(paid, paidMinCents, postId);
-
-  // Per-post "share audio on the fediverse" → set fedi_open on this post's hosted tracks
-  // BEFORE federating, so the Create note carries the right Audio attachments.
-  setAudioFediOpen(site.id, cleanContent, req.body.fedi_open_audio);
 
   if (finalStatus === 'published') {
@@ -424,14 +198,4 @@
       ).run(HtmlSanitizerService.toPlainText(cleanContent), title || '', req.session.user.username, postId);
     } catch (e) { /* FTS index issues are non-fatal */ }
-
-    // ActivityPub: federate a freshly published post to followers. fan_only → delivered
-    // to followers but addressed followers-only (option A: "fans" = your fedi followers).
-    if (status === 'published') {
-      ActivityPubService.deliverCreate(site, {
-        id: postId, slug: finalSlug, title: title || finalSlug,
-        content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null, cover_alt: coverAlt, language,
-        published_at: publishedAt, created_at: now, fan_only: fanOnly, paid, paid_min_cents: paidMinCents, excerpt: excerpt || '', nsfw, content_warning: cw, poll_json: pollJson,
-      }).catch(() => { /* best-effort */ });
-    }
   }
 
@@ -465,17 +229,7 @@
   }
 
-  // A poll with votes is frozen (options can't change) — flag it so the editor disables the poll fields.
-  let pollLocked = false;
-  try { pollLocked = !!(post.poll_json && db.prepare('SELECT 1 FROM poll_votes WHERE post_id = ? LIMIT 1').get(post.id)); } catch { /* ignore */ }
-
   renderPage(req, res, 'pages/post-edit', {
-    // Zelfde modules als de nieuw-route hierboven: zonder deze regel laadt de
-    // editor niet, en dan wist een opslag de post (shaer-5s1, de beet van 7-8).
-    pageJs: 'post-edit playlist-editor',
     post,
     isNew: false,
-    keuzeTypes: KEUZE_TYPES,
-    pollLocked,
-    fediOpenAudio: postAudioFediOpen(site.id, post.content),
     pageTitle: 'Edit: ' + (post.title || 'Untitled'),
     bodyClass: 'on-special',
@@ -497,32 +251,9 @@
   }
 
-  // Verhuisd: een BESTAANDE post bewerken mag nog -- daar wil je juist "ik ben
-  // verhuisd naar ..." in kunnen zetten, en die URI bestaat al. Een concept
-  // alsnog publiceren mag niet: dat is nieuwe inhoud op een adres dat je hebt
-  // opgezegd.
-  if (post.status !== 'published' && String(req.body.status || '') === 'published'
-      && ActivityPubService.movedLock(site).locked) {
-    return res.status(409).send('Dit account is verhuisd. Publiceren doe je op '
-      + ActivityPubService.movedLock(site).movedTo + '. Bestaande berichten bewerken kan hier wel.');
-  }
-
   const { title, content, excerpt, status, pinned, cover_image_url, tags, noindex, type } = req.body;
-  const fanOnly = req.body.fan_only ? 1 : 0;
-  const paid = (premiumUnlocked() && req.body.paid) ? 1 : 0;   // paid posts (klonkt-demo-aki)
-  const paidEur = String(req.body.paid_min_eur || '').replace(',', '.').trim();
-  const paidMinCents = paid && paidEur ? Math.round(parseFloat(paidEur) * 100) : null;
-  const nsfw = req.body.nsfw ? 1 : 0;
-  const cw = (req.body.content_warning || '').trim().slice(0, 200);
-  const coverAlt = (req.body.cover_alt || '').trim().slice(0, 1500) || null; // cover alt text (a11y)
-  const language = /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(req.body.language || '') ? req.body.language : (res.locals.lang || null); // BCP-47 content language
   const newSlug = req.body.slug;
   const action = req.body.action || 'save';
-  const finalType = POST_TYPES.has(type) ? type : (post.type || 'post');
-
-  // A poll that has already received votes is frozen (you can still edit the surrounding
-  // post, but not the options) — changing options after votes would scramble the tally and
-  // is disallowed on the fediverse too. Otherwise re-parse the poll form (add/remove/disable).
-  const hasVotes = !!(post.poll_json && (() => { try { return db.prepare('SELECT 1 FROM poll_votes WHERE post_id = ? LIMIT 1').get(post.id); } catch { return false; } })());
-  const pollJson = hasVotes ? post.poll_json : parsePollForm(req.body);
+  const validTypes = new Set(['post', 'foto', 'video', 'audio']);
+  const finalType = validTypes.has(type) ? type : (post.type || 'post');
 
   // Sanitize before storage — same pipeline as create.
@@ -532,7 +263,8 @@
   if (newSlug && newSlug !== post.slug) {
     const cleaned = newSlug.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
-    const safe = RESERVED_SLUGS.has(cleaned) ? `${cleaned}-post` : cleaned;
-    // Duplicate slug? Make it unique automatically instead of rejecting (own post may keep its slug).
-    finalSlug = uniqueSlug(site.id, safe, post.id);
+    if (RESERVED_SLUGS.has(cleaned)) return res.status(400).send('That slug is reserved');
+    const conflict = db.prepare('SELECT id FROM posts WHERE site_id = ? AND slug = ? AND id != ?').get(site.id, cleaned, post.id);
+    if (conflict) return res.status(400).send('Slug already taken');
+    finalSlug = cleaned;
   }
 
@@ -546,33 +278,18 @@
   }
 
-  // Release planning: published + future publish_at -> 'scheduled'.
-  let publishAt = null;
-  const pa = Date.parse(req.body.publish_at || '');
-  if (req.body.schedule_enabled && finalStatus === 'published' && Number.isFinite(pa) && pa > Date.now()) {
-    finalStatus = 'scheduled';
-    publishAt = new Date(pa).toISOString();
-    publishedAt = null;
-  }
-
   db.prepare(`
     UPDATE posts SET
       title = ?, content = ?, excerpt = ?, status = ?,
-      cover_image_url = ?, cover_video_url = ?, cover_alt = ?, language = ?, pinned = ?, tags = ?,
-      type = ?, noindex = ?, fan_only = ?, nsfw = ?, content_warning = ?, poll_json = ?, publish_at = ?,
+      cover_image_url = ?, pinned = ?, tags = ?,
+      type = ?, noindex = ?,
       slug = ?, published_at = ?, updated_at = ?
     WHERE id = ?
   `).run(
     title, cleanContent, excerpt, finalStatus,
-    cover_image_url || null, (req.body.cover_video_url || null), coverAlt, language, parsePinnedRank(pinned),
+    cover_image_url || null, parsePinnedRank(pinned),
     JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)),
-    finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, pollJson, publishAt,
+    finalType, noindex ? 1 : 0,
     finalSlug, publishedAt, now, post.id
   );
-  cacheRenderedContent(post.id, cleanContent); // re-bake display HTML on edit (ActivityPub `source` model)
-  db.prepare('UPDATE posts SET paid = ?, paid_min_cents = ? WHERE id = ?').run(paid, paidMinCents, post.id);
-
-  // Per-post "share audio on the fediverse" → set fedi_open on this post's hosted tracks
-  // BEFORE federating, so the Update/Create note carries the right Audio attachments.
-  setAudioFediOpen(site.id, cleanContent, req.body.fedi_open_audio);
 
   // Update FTS
@@ -586,29 +303,4 @@
   } catch (e) { /* FTS issues non-fatal */ }
 
-  // ActivityPub: federate edits to followers. A post that BECOMES published →
-  // Create (new post); an already-published post that's edited → Update (so
-  // Mastodon refreshes its cached copy). fan_only → followers-only (option A).
-  if (finalStatus === 'published') {
-    const apPost = {
-      id: post.id, slug: finalSlug, title: title || finalSlug,
-      content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null, cover_alt: coverAlt, language,
-      published_at: publishedAt, created_at: post.created_at, fan_only: fanOnly, paid, paid_min_cents: paidMinCents, excerpt: excerpt || '', nsfw, content_warning: cw, poll_json: pollJson,
-    };
-    // Op een verhuisd account mag een BESTAANDE post nog bewerkt worden -- daar
-    // wil je juist "ik ben verhuisd naar ..." in kunnen zetten, en die URI
-    // bestaat al. Wat niet mag is een concept alsnog publiceren: dat is nieuwe
-    // inhoud op een adres dat je hebt opgezegd. deliverCreate/deliverUpdate
-    // weigeren zelf ook, dit voorkomt alleen de lokale halve toestand.
-    if (post.status !== 'published') ActivityPubService.deliverCreate(site, apPost).catch(() => { /* best-effort */ });
-    else ActivityPubService.deliverUpdate(site, apPost).catch(() => { /* best-effort */ });
-  }
-
-  // Pin/unpin/reorder → push Add/Remove activities so followers' instances update the
-  // pinned order immediately (reliable, unlike re-fetching the cached featured collection).
-  if ((post.pinned || 0) !== parsePinnedRank(pinned)) {
-    const unpinned = (post.pinned || 0) > 0 && parsePinnedRank(pinned) === 0 ? [post.id] : [];
-    ActivityPubService.resyncFeaturedPins(site, unpinned).catch(() => { /* best-effort */ });
-  }
-
   res.redirect(`${res.locals.siteUrlBase || ''}/${finalSlug}`);
 });
@@ -626,11 +318,4 @@
   if (!PermissionsService.canDeletePost(req.session.user, post, site)) {
     return res.status(403).send('No permission');
-  }
-
-  // ActivityPub: tell followers the post is gone (Delete + Tombstone) if it was
-  // federated (any published post now federates — fan_only goes followers-only).
-  // Fire before the row is removed — we still have post.id (= the Note id).
-  if (post.status === 'published') {
-    ActivityPubService.deliverDelete(site, post).catch(() => { /* best-effort */ });
   }
 
@@ -686,36 +371,31 @@
 });
 
-// Local likes/favourites are removed — engagement is fediverse-only now
-// (the ⭐ on a post likes via the fediverse). No post_likes, no /favorieten.
-
-// Newer/Older neighbours across ALL posts in feed order. Shared by the full
-// post render and the fan gate (premium fan_only) so navigation is consistent
-// everywhere. Solo: within the site (pinned first, then date). Hub: globally by date.
-// Renders a post's display HTML: baked content + the dynamic audio/embed layer.
-// Extracted so the paid unlock (slice 4) serves the exact same body as the page.
-// Dezelfde berichten, klaar voor de leesweergave.
-//
-// Tijdlijn en Grid tonen kaartjes; Lezen toont het hele stuk. Het is dus geen
-// andere PAGINA maar een andere vorm van dezelfde rijen -- vandaar dat de feed
-// ze alledrie meestuurt en CSS kiest, precies zoals timeline/grid dat al deden.
-//
-// Het lijf loopt door PostAccessService: een gesloten poort levert hier GEEN
-// tekst op, want wat niet gerenderd wordt kan ook niet lekken.
-function readerItems(site, rows, req) {
-  const viewer = OWA.viewerFor(req, site, { unlockedSlug: null });
-  return rows.map((post) => ({
-    post,
-    entry: postEntry(post, viewer, { renderBody: (p) => renderPostBodyHtml(site, p, req) }),
-  }));
-}
-
-export function renderPostBodyHtml(site, post, req) {
-  let html = (post.content_rendered != null && post.content_rendered !== '')
-    ? post.content_rendered
-    : ActivityPubService.bakePostContent(post.content || '');
-  if (audioEnabled()) {
+// ==================== VIEW POST (last route â€” catches /:slug) ====================
+router.get('/:slug', (req, res, next) => {
+  if (RESERVED_SLUGS.has(req.params.slug)) return next();
+
+  const site = res.locals.site;
+  if (!site) return res.status(404).send('Site not found');
+
+  const post = db.prepare(`
+    SELECT p.*, u.username as author_username, u.avatar_url as author_avatar
+    FROM posts p JOIN users u ON p.author_id = u.id
+    WHERE p.site_id = ? AND p.slug = ?
+  `).get(site.id, req.params.slug);
+
+  if (!post) return res.status(404).send('Post not found');
+
+  // Permission to view: published OR (logged in + can edit)
+  if (post.status !== 'published') {
+    const canEdit = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
+    if (!canEdit) return res.status(403).send('Not published');
+  }
+
+  // Render content. Content is now user-authored HTML (already sanitized on
+  // save). The pipeline still adds autoembed iframes and shortcode embeds:
+  //   stored HTML → autoembed → [[track]]/[[album]]/[[playlist]] → response
+  let html = post.content || '';
   if (site.enable_audio_player !== 0) {
     html = AudioEmbedService.autoembed(html);
-    html = AudioEmbedService.embedMediaShortcodes(html);
     html = AudioEmbedService.embedExternalLinkShortcodes(html);
 
@@ -726,6 +406,5 @@
       const placeholders = trackIds.map(() => '?').join(',');
       const rows = db.prepare(`
-        SELECT t.id, t.title, t.artist, t.cover_url, t.credit, t.license,
-               t.link_spotify, t.link_youtube, t.link_soundcloud, m.filename
+        SELECT t.id, t.title, t.artist, t.cover_url, m.filename
         FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
         WHERE t.site_id = ? AND t.id IN (${placeholders})
@@ -734,5 +413,5 @@
       html = AudioEmbedService.embedTrackShortcodes(html, (id) => {
         const r = byId.get(id);
-        if (!r) return null;
+        if (!r || !r.filename) return null;
         return {
           id: r.id,
@@ -740,10 +419,5 @@
           artist: r.artist,
           cover: r.cover_url,
-          credit: r.credit || '',
-          license: r.license || '',
-          link_spotify: r.link_spotify || '',
-          link_youtube: r.link_youtube || '',
-          link_soundcloud: r.link_soundcloud || '',
-          url: r.filename ? audioUrl(r.filename) : '',  // '' = link-only track
+          url: signUrl(r.filename).url,
         };
       });
@@ -755,6 +429,5 @@
       const placeholders = albumNames.map(() => '?').join(',');
       const albumRows = db.prepare(`
-        SELECT t.id, t.title, t.artist, t.album, t.cover_url, t.position,
-               t.link_spotify, t.link_youtube, t.link_soundcloud, m.filename
+        SELECT t.id, t.title, t.artist, t.album, t.cover_url, t.position, m.filename
         FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
         WHERE t.site_id = ? AND t.album IN (${placeholders})
@@ -763,15 +436,11 @@
       const byAlbum = new Map();
       for (const r of albumRows) {
-        // Link-only tracks (no file) remain in the album overview (url '').
+        if (!r.filename) continue;
         if (!byAlbum.has(r.album)) byAlbum.set(r.album, []);
         byAlbum.get(r.album).push({
-          id: r.id,
-          url: r.filename ? audioUrl(r.filename) : '',
+          url: signUrl(r.filename).url,
           title: r.title || 'Untitled',
           artist: r.artist || '',
           cover: r.cover_url || '',
-          link_spotify: r.link_spotify || '',
-          link_youtube: r.link_youtube || '',
-          link_soundcloud: r.link_soundcloud || '',
         });
       }
@@ -795,855 +464,9 @@
       const isAdmin = req.session?.user?.role === 'god';
       html = AudioEmbedService.embedPlaylistShortcodes(html, (id) => {
-        return PlaylistService.get(site.id, id, audioUrl);
+        return PlaylistService.get(site.id, id, signUrl);
       }, { isAdmin });
     }
   }
-  } else {
-    // LITE mode (KLONKT_AUDIO=off): no own audio (no ffmpeg/stream route).
-    // External embeds (YouTube/SoundCloud/Spotify) remain; the own-audio
-    // shortcodes ([[track]]/[[album]]/[[playlist]]) are cleanly stripped.
-    html = AudioEmbedService.autoembed(html);
-    html = AudioEmbedService.embedMediaShortcodes(html);
-    html = AudioEmbedService.embedExternalLinkShortcodes(html);
-    html = html.replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
-  }
-  return html;
-}
-
-// A short public teaser for a paid post: its excerpt, else the first ~280 chars
-// of the (stripped) content. Shared by the web gate and federation.
-function paidTeaser(post, max = 280) {
-  if (post && post.excerpt && String(post.excerpt).trim()) return String(post.excerpt).trim();
-  // Only the FIRST paragraph: a paid teaser must never spill later content.
-  const html = String((post && post.content) || '');
-  const firstP = (html.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || [null, html])[1] || '';
-  const text = firstP.replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim();
-  return text.length > max ? text.slice(0, max).replace(/\s+\S*$/, '') + '…' : text;
-}
-
-// De muziek van een betaalde post op de poortpagina zelf.
-//
-// WAAROM DIE DAAR HOORT. Zodra een nummer `fedi_open` is, federeert het als
-// eigen Audio-object en speelt het bij iedereen die de post in een hub of in
-// Mastodon tegenkomt. Toonde de poort het dan NIET, dan was de muziek overal
-// beschikbaar behalve op de site die hem uitbrengt -- en dat is de verkeerde
-// kant op (Robin, 24-8). De muur staat om de tekst.
-//
-// ALLES OF NIETS. Alleen als ELK nummer waar de post naar wijst open staat.
-// Een shortcode rendert zijn hele lijst, dus bij een half-open bandje zou de
-// speler ook de gesloten nummers krijgen -- en /audio/stream laat een
-// gelijke-oorsprong-fetch door, dus dat is geen theoretisch lek maar een echt.
-// Half open is hier dus dicht.
-//
-// De TEKST komt hier niet langs: we geven renderPostBodyHtml een post mee die
-// alleen uit de audio-shortcodes bestaat. Wat niet meegegeven wordt kan ook
-// niet lekken -- dezelfde regel als bij readerItems.
-export function paidOpenAudioHtml(site, post, req) {
-  if (!postAudioFediOpen(site.id, post.content)) return '';
-  const codes = String(post.content || '').match(/\[\[(?:track|album|playlist):[^\]]+\]\]/gi) || [];
-  if (!codes.length) return '';
-  const alleenMuziek = codes.join('\n');
-  try {
-    return renderPostBodyHtml(site, { ...post, content: alleenMuziek, content_rendered: alleenMuziek }, req);
-  } catch { return ''; /* geen speler is geen kapotte poort */ }
-}
-
-function postNeighbors(site, post) {
-  const ordered = db.prepare(`
-    SELECT id, slug, title, pinned FROM posts
-    WHERE site_id = ? AND status = 'published'
-    ORDER BY (pinned = 0) ASC, pinned ASC, published_at DESC
-  `).all(site.id);
-  const idx = ordered.findIndex((p) => p.id === post.id);
-  const newerPost = idx > 0 ? ordered[idx - 1] : null;
-  const olderPost = (idx >= 0 && idx < ordered.length - 1) ? ordered[idx + 1] : null;
-  if (newerPost) newerPost._urlBase = '';
-  if (olderPost) olderPost._urlBase = '';
-  return { newerPost, olderPost };
-}
-
-// ==================== REMOTE INTERACTION (reply to a fediverse post as your site) ====================
-// Standard fediverse "reply from your own server" landing endpoint. A post page
-// elsewhere bounces the visitor here with ?uri=<remote post>; the site owner
-// composes a reply that federates back to that post.
-router.get('/authorize_interaction', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const uri = (req.query.uri || '').toString();
-  const sent = !!req.query.sent;
-  const followed = !!req.query.followed;
-  const voted = !!req.query.voted;
-  const reported = !!req.query.reported;
-  let target = null, followTarget = null;
-  if (!sent && !followed && !voted && !reported && uri) {
-    try { target = await ActivityPubService.resolveRemoteNote(uri); } catch { /* ignore */ }
-    // Not a post? Maybe the URI is a profile/actor → offer Follow, not reply.
-    if (!target) { try { followTarget = await ActivityPubService.resolveRemoteActor(uri); } catch { /* ignore */ } }
-  }
-  renderPage(req, res, 'pages/authorize-interaction', {
-    pageJs: 'authorize-interaction reply-editor',
-    pageTitleKey: 'fedi.remote_interact', // i18n: was hardcoded Dutch on non-NL sites
-    bodyClass: 'on-special',
-    uri,
-    target,
-    followTarget,
-    sent,
-    followed,
-    voted: !!req.query.voted,
-    reported: !!req.query.reported,
-    liked: !!req.query.liked,
-    boosted: !!req.query.boosted,
-    reacted: (site && uri) ? ActivityPubService.getReaction(site.slug, uri) : { liked: false, boosted: false },
-    siteTitle: site ? site.title : '',
-  });
-});
-
-// 📊 Vote on a remote fediverse poll from the interact page (any poll by URL, not just
-// followed ones). Casts the Mastodon-standard ballot straight to the poll's author.
-router.post('/authorize_interaction/vote', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const uri = (req.body.uri || '').toString();
-  let choice = req.body.choice;
-  if (choice == null) choice = [];
-  if (!Array.isArray(choice)) choice = [choice];
-  if (site && uri && choice.length) { try { await ActivityPubService.voteOnRemotePoll(site, uri, choice.map(String)); } catch { /* ignore */ } }
-  res.redirect('/authorize_interaction?voted=1&uri=' + encodeURIComponent(uri));
-});
-
-// 🚩 Report a remote post/account to its home instance (sends an AS2 Flag).
-router.post('/authorize_interaction/report', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const uri = (req.body.uri || '').toString();
-  const actorUri = (req.body.actor_uri || '').toString();
-  const reason = (req.body.reason || '').toString();
-  if (site && (uri || actorUri)) { try { await ActivityPubService.sendReport(site, { objectUri: uri, actorUri, reason }); } catch { /* ignore */ } }
-  res.redirect('/authorize_interaction?reported=1&uri=' + encodeURIComponent(uri || actorUri));
-});
-
-// ⭐ Like / unlike a remote post from your own site (toggle on the interact page).
-router.post('/authorize_interaction/like', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const uri = (req.body.uri || '').toString();
-  let on = false;
-  if (site && uri) {
-    on = !ActivityPubService.getReaction(site.slug, uri).liked;
-    ActivityPubService.resolveRemoteNote(uri)
-      .then((note) => note && ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note.object_uri || uri, note.actor_uri))
-      .catch((e) => console.warn('[AP] remote like failed:', e.message));
-    // Eén schrijfpad (shaer-9e9): tussentabel + afgeleide vlag.
-    ActivityPubService.setReaction(site.slug, uri, 'like', on);
-  }
-  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
-  res.redirect('/authorize_interaction?uri=' + encodeURIComponent(uri));
-});
-
-// 🔁 Boost / unboost a remote post from your own site (toggle on the interact page).
-// Also flags it for the Cirkel (markBoosted is a no-op if the post isn't in your timeline).
-router.post('/authorize_interaction/boost', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const uri = (req.body.uri || '').toString();
-  let on = false;
-  if (site && uri) {
-    on = !ActivityPubService.getReaction(site.slug, uri).boosted;
-    ActivityPubService.resolveRemoteNote(uri)
-      .then((note) => {
-        if (!note) return;
-        const id = note.object_uri || uri;
-        return Promise.resolve(ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', id, note.actor_uri))
-          // De note gaat mee: een boost zet niet alleen een vlag maar trekt de
-          // post je tijdlijn in, ook als je de auteur niet volgt, zodat hij in
-          // de Cirkel verschijnt.
-          .then(() => ActivityPubService.setReaction(site.slug, uri, 'boost', on, { flagUri: id, note: on ? note : null }));
-      })
-      .catch((e) => console.warn('[AP] remote boost failed:', e.message));
-    // Meteen zetten, zodat de knop klopt voordat de resolve terug is. Via
-    // setReaction en niet via setMyReaction: ook dit korte moment mag geen
-    // halve schrijfactie zijn. De resolve hierboven werkt hem daarna bij met de
-    // note, zodat de post ook in je tijdlijn belandt.
-    ActivityPubService.setReaction(site.slug, uri, 'boost', on);
-  }
-  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
-  res.redirect('/authorize_interaction?uri=' + encodeURIComponent(uri));
-});
-
-// Follow a remote actor from your own site (when the target is a profile, not a post).
-router.post('/authorize_interaction/follow', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const uri = (req.body.uri || '').toString();
-  if (!site || !uri) return res.redirect('/authorize_interaction?followed=1&uri=' + encodeURIComponent(uri));
-  // Afwachten in plaats van wegsturen: ligt het verzoek bij de guardians, dan
-  // moet dat op het scherm staan (shaer-p729). "followed=1" terwijl er niets
-  // gebeurd is, is precies de leugen die de poort waardeloos maakt.
-  ActivityPubService.followActor(site, uri)
-    .then((r) => res.redirect('/authorize_interaction?' + (r && r.held ? 'held=1' : 'followed=1') + '&uri=' + encodeURIComponent(uri)))
-    .catch((e) => {
-      console.warn('[AP] remote follow failed:', e.message);
-      res.redirect('/authorize_interaction?error=1&uri=' + encodeURIComponent(uri));
-    });
-});
-
-router.post('/authorize_interaction', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const uri = (req.body.uri || '').toString();
-  const text = (req.body.text || '').toString();
-  const html = (req.body.content || '').toString();      // rich reply editor HTML (sanitized in deliverReply)
-  const language = (req.body.language || '').toString();
-  let attachments = [];
-  try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
-  let mentions;   // undefined = geen balk meegestuurd (legacy addressing)
-  try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
-  if (site && uri && (text.trim() || html.trim() || (Array.isArray(attachments) && attachments.length))) {
-    // Resolve + deliver in the background so Send responds instantly.
-    ActivityPubService.resolveRemoteNote(uri)
-      .then((parent) => parent && ActivityPubService.deliverReply(site, { postId: parent.localPostId || '', postSlug: null, parent, text, html, language, attachments, mentions }))
-      .catch((e) => console.warn('[AP] remote reply failed:', e.message));
-  }
-  res.redirect('/authorize_interaction?sent=1&uri=' + encodeURIComponent(uri));
-});
-
-// Manage / delete your own outbound fediverse replies (site owner only).
-// Messages = Reacties + Meldingen in ONE inbox (your sent replies join the stream).
-// The old /fediverse (manage) and /notifications pages redirect here.
-router.get('/messages', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const append = req.query.append === '1';
-  const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
-  const page = gateEmbeds(site, site ? ActivityPubService.getMessages(site.slug, FEED_PAGE + 1, offset) : []);
-  const hasMore = page.length > FEED_PAGE;
-  const items = page.slice(0, FEED_PAGE);
-  // Read the watermark BEFORE marking seen → unread dots on items newer than last visit.
-  const seenAt = site ? ActivityPubService.notificationsSeenAt(site.slug) : 0;
-  // Only stamp "seen" on the first page load (not on Load-more appends).
-  if (site && !append && !isViewer(req.session.user)) ActivityPubService.markNotificationsSeen(site.slug);
-  const moreBase = res.locals.siteUrlBase || '';
-  if (append) {
-    return renderPage(req, res, 'partials/messages-append', { items, seen: seenAt, hasMore, nextOffset: offset + FEED_PAGE, moreBase });
-  }
-  // FEP-633c: pending guardianship offers TO this account (I am the ward)
-  // show as a special message with an accept button (Robins besluit: the kid
-  // answers in its own Klonkt; safety is out-of-band by the guardians).
-  const gBase = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const gMe = site ? ActivityPubService.actorId(gBase, site.slug) : null;
-  const guardianOffers = (site
-    ? Guardianship.offersCollection(`${gMe}/queues/offers`, site.slug, gMe).orderedItems
-    : []).filter((o) => o['shaer:ward'] === gMe && o['shaer:needsMyAccept']);
-  renderPage(req, res, 'pages/messages', {
-    pageTitleKey: 'msg.title', bodyClass: 'on-special', pageJs: 'messages reply-editor', items, seenAt,
-    hasMore, nextOffset: offset + FEED_PAGE, moreBase, guardianOffers,
-    success: req.query.success || null, error: req.query.error || null,
-  });
-});
-
-// The kid answers a guardianship offer from Berichten: the same C2S
-// Accept/Reject pipeline the Shaer apps use (one path, one behavior).
-router.post('/messages/guardianship', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const back = `${res.locals.siteUrlBase || ''}/messages`;
-  const answer = req.body.answer === 'accept' ? 'Accept' : (req.body.answer === 'reject' ? 'Reject' : null);
-  const offer = String(req.body.offer || '').trim();
-  if (!site || !answer || !offer) return res.redirect(back + '?error=guardianship');
-  try {
-    // Same C2S Accept/Reject the apps use; the handshake module records the
-    // ward's accept and (once the candidate returns the handle) commits.
-    const r = await ActivityPubService.ingestOutboxActivity(site, req.session.user, { type: answer, object: offer });
-    if (r && r.status < 400) return res.redirect(back + '?success=' + (answer === 'Accept' ? 'guardian_accepted' : 'guardian_rejected'));
-  } catch { /* fall through */ }
-  res.redirect(back + '?error=guardianship');
-});
-// A ward answers a guardian's wave without publishing: a canned private note
-// back to the sender (FEP-633c §5, shaer:wave reply). Same direct-note leg.
-router.post('/messages/quick-reply', requireSiteManager, express.urlencoded({ extended: false }), async (req, res) => {
-  const site = res.locals.site;
-  const back = `${res.locals.siteUrlBase || ''}/messages`;
-  const to = String(req.body.to || '').trim();
-  const text = String(req.body.text || '').trim().slice(0, 200);
-  // Zwaaien is een seintje, en een seintje hoort de pagina niet te herladen.
-  // De module stuurt hem met X-Requested-With: fetch en krijgt JSON terug;
-  // zonder JS blijft het formulier gewoon posten en omleiden.
-  const viaFetch = req.get('X-Requested-With') === 'fetch';
-  const mis = (reden) => (viaFetch ? res.status(400).json({ ok: false, error: reden }) : res.redirect(back + '?error=' + reden));
-  if (!site || !/^https?:\/\//i.test(to) || !text) return mis('quickreply');
-  try {
-    const r = await ActivityPubService.deliverDirectNote(site, { recipients: [to], text, wave: true });
-    if (r) return viaFetch ? res.json({ ok: true }) : res.redirect(back + '?success=wave_sent');
-  } catch { /* fall through */ }
-  return mis('quickreply');
-});
-
-// Antwoorden vanuit een gesprek in Berichten. Twee paden, en welke het wordt
-// bepaalt de draad zelf (zie groupConversations → replyTo):
-//   - hangt de draad aan een post van jou, dan is dit een gewone reply op het
-//     nieuwste ontvangen bericht erin: deliverReply, publiek zoals de thread;
-//   - hangt hij aan een persoon, dan is het een direct bericht terug.
-// Rijk in beide gevallen: `content` is de HTML uit de reply-editor, `text` de
-// platte versie die de editor er altijd bij levert (en die het no-JS-formulier
-// als enige stuurt).
-router.post('/messages/reply', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const back = `${res.locals.siteUrlBase || ''}/messages`;
-  if (!site) return res.status(404).send('Site required');
-  const text = String(req.body.text || '');
-  const html = String(req.body.content || '');
-  let attachments = [];
-  try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
-  let mentions;
-  try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
-  const language = String(req.body.language || '');
-  // Leeg is leeg: een bericht zonder tekst EN zonder media is geen bericht.
-  if (!text.trim() && !html.trim() && !attachments.length) return res.redirect(back + '?error=reply_empty');
-
-  const interactionId = parseInt(req.body.interaction_id, 10) || 0;
-  const postSlug = String(req.body.post_slug || '');
-  const toActor = String(req.body.to || '');
-  try {
-    if (interactionId && postSlug) {
-      const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, postSlug);
-      const parent = ActivityPubService.getInteractionById(interactionId);
-      // De parent MOET bij deze post horen: anders zou een gemanipuleerd
-      // formulier een antwoord onder andermans draad kunnen hangen.
-      if (!post || !parent || parent.post_id !== post.id) return res.redirect(back + '?error=reply_target');
-      await ActivityPubService.deliverReply(site, {
-        postId: post.id, postSlug: post.slug, parent, text, html, attachments, mentions, language,
-      });
-    } else if (/^https?:\/\//i.test(toActor)) {
-      const r = await Guardianship.deliverDirectNote(site, { recipients: [toActor], text, html, language, attachments });
-      if (!r) return res.redirect(back + '?error=reply_failed');
-    } else {
-      return res.redirect(back + '?error=reply_target');
-    }
-  } catch (e) {
-    console.warn('[AP] reply from Berichten failed:', e.message);
-    return res.redirect(back + '?error=reply_failed');
-  }
-  res.redirect(back + '?success=reply_sent');
-});
-
-router.get('/fediverse', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/messages`));
-
-router.post('/fediverse/:id/delete', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  if (site) {
-    try { await ActivityPubService.deliverOutboxDelete(site, req.params.id); }
-    catch (e) { console.warn('[AP] outbox delete failed:', e.message); }
-  }
-  res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/fediverse`);
-});
-
-// Moderation: remove an INCOMING reply from your thread (owner only). Tombstones the
-// object URI so re-delivery and thread-crawling never bring it back. Works for private
-// notes too (acts on the local copy; no remote fetch involved).
-router.post('/interactions/:id/remove', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  if (site) {
-    const r = ActivityPubService.rejectInteraction(site, parseInt(req.params.id, 10) || 0, 'removed by site owner');
-    if (r.error) console.warn('[AP] interaction remove failed:', r.error);
-  }
-  res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/`);
-});
-
-// Moderation: report an INCOMING reply to its home instance (owner only). Uses the
-// locally stored object/actor URIs, so it also works for private notes that
-// authorize_interaction cannot fetch (401/404).
-router.post('/interactions/:id/report', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  if (site) {
-    const tgt = ActivityPubService.interactionReportTarget(site, parseInt(req.params.id, 10) || 0);
-    if (tgt && (tgt.objectUri || tgt.actorUri)) {
-      try {
-        const r = await ActivityPubService.sendReport(site, { objectUri: tgt.objectUri, actorUri: tgt.actorUri, reason: (req.body.reason || '').toString().slice(0, 500) });
-        if (r && r.error) console.warn('[AP] interaction report failed:', r.error);
-      } catch (e) { console.warn('[AP] interaction report failed:', e.message); }
-    }
-  }
-  res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/`);
-});
-
-// Edit one of your own outbound fediverse replies (owner only) → sends an Update(Note).
-router.post('/fediverse/:id/edit', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const text = String(req.body.text || '');
-  const html = String(req.body.content || '');   // rich reply editor HTML (sanitized in deliverOutboxUpdate)
-  if (site && (text.trim() || html.trim())) {
-    try {
-      await ActivityPubService.deliverOutboxUpdate(site, req.params.id, text, {
-        html, language: String(req.body.language || ''),
-      });
-    } catch (e) { console.warn('[AP] outbox edit failed:', e.message); }
-  }
-  res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/fediverse`);
-});
-
-// ==================== FEDIVERSE CLIENT: home timeline + following ====================
-// Build a direct embed iframe for the first embeddable link (YouTube/Spotify/
-// SoundCloud/Vimeo) in a remote post's content, so others' media plays inline.
-function timelineEmbedHtml(html) {
-  if (!html) return null;
-  const re = /href=["']([^"']+)["']/gi; let m; const seen = new Set();
-  while ((m = re.exec(html))) {
-    const u = m[1]; if (seen.has(u)) continue; seen.add(u);
-    let p; try { p = AudioEmbedService.detectProvider(u); } catch { p = null; }
-    if (!p) {
-      // PeerTube is decentralised (any instance), so it's not in detectProvider — match its watch URL
-      // (/w/<id> or /videos/watch/<id>) and embed the player. Host is validated (safe chars only), so
-      // it's safe to inline into the iframe src; a non-PeerTube /w/ URL just yields an empty iframe.
-      const pt = u.match(/^https?:\/\/([\w.-]+(?::\d+)?)\/(?:w|videos\/watch)\/([\w-]{6,})/i);
-      if (pt) return `<iframe class="tl-embed-frame" src="https://${pt[1]}/videos/embed/${pt[2]}" title="PeerTube" loading="lazy" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>`;
-      continue;
-    }
-    if (p.provider === 'youtube') return `<iframe class="tl-embed-frame" src="https://www.youtube-nocookie.com/embed/${p.id}" title="YouTube" loading="lazy" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>`;
-    if (p.provider === 'spotify') return `<iframe class="tl-embed-frame tl-embed-spotify" src="https://open.spotify.com/embed/${p.type}/${p.id}" title="Spotify" loading="lazy" frameborder="0" allow="encrypted-media"></iframe>`;
-    if (p.provider === 'soundcloud') return `<iframe class="tl-embed-frame tl-embed-sc" src="https://w.soundcloud.com/player/?url=${encodeURIComponent(p.url)}&color=%23ff5500&visual=false" title="SoundCloud" loading="lazy" frameborder="0" allow="autoplay" scrolling="no"></iframe>`;
-    if (p.provider === 'vimeo') return `<iframe class="tl-embed-frame" src="https://player.vimeo.com/video/${p.id}" title="Vimeo" loading="lazy" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>`;
-    if (p.provider === 'bandcamp') return `<iframe class="tl-embed-frame tl-embed-bandcamp" src="https://bandcamp.com/EmbeddedPlayer/url=${encodeURIComponent(u)}/size=large/bgcol=faf8f3/linkcol=c2410c/tracklist=false/transparent=true/" title="Bandcamp" loading="lazy" frameborder="0" allow="encrypted-media"></iframe>`;
-    if (p.provider === 'applemusic') { const am = u.match(/music\.apple\.com\/([a-z]{2}\/(?:album|playlist|song)\/[^/?#]+\/[0-9]+)/i); if (am) return `<iframe class="tl-embed-frame tl-embed-apple" src="https://embed.music.apple.com/${am[1]}" title="Apple Music" loading="lazy" frameborder="0" allow="autoplay; encrypted-media"></iframe>`; }
-  }
-  return null;
-}
-
-// A federated Klonkt audio post renders as "🎵 … listen on <link>". Embed the remote
-// Klonkt player (its /embed?post=<slug>). A single-segment path = a Klonkt post slug
-// (skips Mastodon /@user/123). The origin is whitelisted in the response CSP frame-src.
-function klonktAudioEmbed(html, url) {
-  if (!html || !url || html.indexOf('🎵') < 0) return null;
-  let u; try { u = new URL(url); } catch { return null; }
-  if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
-  const slug = u.pathname.replace(/^\/+|\/+$/g, '');
-  if (!slug || slug.indexOf('/') >= 0) return null; // single segment only
-  const src = u.origin + '/embed?post=' + encodeURIComponent(slug);
-  // Drop the now-redundant "🎵 … listen on <site>" line — the embedded player below shows it.
-  const content = html.replace(/<p>🎵[\s\S]*?<\/p>\s*/i, '');
-  return { origin: u.origin, embedUrl: src, content, html: `<iframe class="tl-embed-frame tl-embed-klonkt" src="${src}" title="Audio" loading="lazy" frameborder="0" allow="autoplay; encrypted-media"></iframe>` };
-}
-
-/**
- * FEP-633c §5.3-style gated feature: may this account see previews of links
- * that point OUTSIDE the fediverse? For a ward that is the guardians' call.
- *
- * Applied at SERVE time on every surface, the way the app's inbox read already
- * does it (routes/activitypub.js): a card the client merely hides has still
- * been delivered.
- */
-function gateEmbeds(site, rows) {
-  if (!site || !rows.length) return rows;
-  if (embedsAllowedFor(site)) return rows;
-  return rows.map((r) => (r && r.embed_json ? { ...r, embed_json: null } : r));
-}
-
-function isWardSite(site) {
-  try { return !!site && Guardianship.listGuardians(site.slug).length > 0; } catch { return false; }
-}
-function embedsAllowedFor(site) {
-  return !site || Guardianship.externalEmbedsAllowed(site.external_embeds, isWardSite(site));
-}
-/**
- * May a third-party PLAYER run inside this page? (FEP-633c 5.6, the heavier
- * sibling of the preview gate.) This was the hole: the player iframe is built
- * from the note's content by timelineEmbedHtml, on a path that never touched
- * gateEmbeds. A ward whose guardians had allowed nothing still got the full
- * YouTube player on the web, while the app showed nothing at all: the heavy
- * thing open, the light thing shut. Playback also requires the preview gate,
- * because you cannot play what you may not see.
- */
-function playbackAllowedFor(site) {
-  if (!site) return true;
-  if (!embedsAllowedFor(site)) return false;
-  return Guardianship.externalPlaybackAllowed(site.external_playback, isWardSite(site));
-}
-
-router.get('/news', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const append = req.query.append === '1';
-  const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
-  const cspOrigins = new Set();
-  // Fetch one extra to know whether a "Load more" button belongs on this page.
-  const rows = gateEmbeds(site, site ? ActivityPubService.getTimeline(site.slug, FEED_PAGE + 1, offset) : []);
-  const hasMore = rows.length > FEED_PAGE;
-  // Players (a third party's engine inside our page) ride the playback gate;
-  // a Klonkt site's own audio embed is ours and stays.
-  const mayPlay = playbackAllowedFor(site);
-  const timeline = rows.slice(0, FEED_PAGE).map((p) => {
-    let embedHtml = mayPlay ? timelineEmbedHtml(p.content) : null;
-    let content = p.content;
-    let embedUrl = null;
-    if (!embedHtml) {
-      const k = klonktAudioEmbed(p.content, p.url);
-      if (k) { embedHtml = k.html; content = k.content; embedUrl = k.embedUrl; cspOrigins.add(k.origin); }
-    }
-    // embedUrl = the player's direct /embed?post=… URL. Surfaced so the view can offer a
-    // top-level "open the player" link that works even when a browser shield/CSP blocks
-    // the cross-site iframe (a full-page navigation is not a cross-site frame).
-    let poll = null;
-    if (p.poll_json) { try { poll = JSON.parse(p.poll_json); } catch { /* ignore */ } }
-    return { ...p, content, embedHtml, embedUrl, poll };
-  });
-  // Option A: allow the followed Klonkt sites' player iframes (you follow them) by
-  // extending ONLY this response's CSP frame-src. The global policy stays locked down.
-  if (cspOrigins.size) {
-    const csp = res.getHeader('Content-Security-Policy');
-    if (csp) {
-      const extra = [...cspOrigins].join(' ');
-      res.setHeader('Content-Security-Policy', String(csp).replace(/frame-src ([^;]*)/i, (m, g) => `frame-src ${g} ${extra}`));
-    }
-  }
-  const moreBase = res.locals.siteUrlBase || '';
-  if (append) {
-    return renderPage(req, res, 'partials/news-append', { timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase });
-  }
-  renderPage(req, res, 'pages/news', {
-    pageJs: 'news',
-    pageTitle: 'News', bodyClass: 'on-special',
-    timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase,
-    success: req.query.success || null, error: req.query.error || null,
-  });
-});
-
-// Volgend — manage the accounts you follow (+ per-account auto-boost toggles).
-// Connect = who you follow + who follows you, merged into one page with direction
-// (following →, follower ←, mutual ↔) and per-account delivery health. Replaces the
-// separate Following/Followers pages, which redirect here so old links keep working.
-router.get('/connect', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const connections = site ? ActivityPubService.listConnections(site.slug) : [];
-  // FEP-633c §2: the ward always sees who guards it, and §3.6 how available
-  // each of them is. Connect is where "who am I connected to" belongs; a
-  // guardian is the one connection a ward should never have to hunt for.
-  // Owner-only by construction: this page is the owner's.
-  const guardianHandle = (uri, cached) => {
-    if (cached && cached.charAt(0) === '@') return cached;
-    try { const u = new URL(uri); return `@${u.pathname.split('/').filter(Boolean).pop()}@${u.host}`; }
-    catch { return uri; }
-  };
-  const gStatus = site ? Object.fromEntries(
-    Guardianship.availability.statusesFor(site.slug, Guardianship.listGuardians(site.slug).map((g) => g.other_uri), Date.now())
-      .map((s) => [s.id, s]),
-  ) : {};
-  const myGuardians = (site ? Guardianship.listGuardians(site.slug) : [])
-    .map((g) => ({
-      uri: g.other_uri,
-      handle: guardianHandle(g.other_uri, g.other_handle),
-      availability: (gStatus[g.other_uri] || {})['shaer:availability'] || 'active',
-      awayUntil: (gStatus[g.other_uri] || {})['shaer:awayUntil'] || null,
-    }));
-  // De eigenaarspoort: openstaande volgverzoeken, alleen buiten voogdij.
-  // Een ward-follow beslissen de guardians — die tonen we hier dus NIET,
-  // anders is deze pagina een deur naast hun poort.
-  const followRequests = (site && !myGuardians.length)
-    ? Guardianship.follows.listForWard(site.slug) : [];
-  renderPage(req, res, 'pages/connect', {
-    pageTitle: 'Connect', bodyClass: 'on-special',
-    connections, myGuardians, followRequests,
-    approveFollowers: !!(site && site.approve_followers),
-    // Na een verhuizing staat de uitgaande kant op slot. Dat hoort te blijken
-    // VOORDAT je op een knop drukt, niet daarna uit een foutmelding.
-    movedTo: ActivityPubService.movedLock(site).movedTo,
-    success: req.query.success || null, error: req.query.error || null,
-  });
-});
-router.get('/following', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/connect`));
-router.get('/followers', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/connect`));
-
-router.post('/followers/:id/remove', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const base = res.locals.siteUrlBase || '';
-  if (!site) return res.redirect(`${base}/connect`);
-  const ok = ActivityPubService.removeFollower(site.slug, parseInt(req.params.id, 10) || 0);
-  return res.redirect(`${base}/connect?` + (ok
-    ? 'success=' + encodeURIComponent('Volger verwijderd')
-    : 'error=' + encodeURIComponent('Volger niet gevonden')));
-});
-
-// De poort zelf aan- of uitzetten, op de plek waar de verzoeken toch al
-// staan (Robins wens, 18-8: "op de connect is logischer").
-router.post('/connect/approve-followers', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const base = res.locals.siteUrlBase || '';
-  if (site) {
-    db.prepare('UPDATE sites SET approve_followers = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
-      .run(req.body.on ? 1 : 0, site.id);
-  }
-  return res.redirect(`${base}/connect`);
-});
-
-// De eigenaarspoort beslist (Robins wens, 18-8): accepteer of weiger een
-// volgverzoek dat door approve_followers is vastgehouden. Bewust NIET voor
-// wards — daar beslissen de guardians, en deze route weigert dan hard, zodat
-// hij geen sluiproute naast die poort wordt.
-router.post('/follow-requests/:decision', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const base = res.locals.siteUrlBase || '';
-  const { decision } = req.params;
-  if (!site || !['approve', 'deny'].includes(decision)) return res.redirect(`${base}/connect`);
-  if (Guardianship.listGuardians(site.slug).length) {
-    return res.redirect(`${base}/connect?error=` + encodeURIComponent('Volgverzoeken lopen via je guardians'));
-  }
-  const pending = Guardianship.follows.getPending(String(req.body.id || ''));
-  if (!pending || pending.ward_slug !== site.slug || pending.status !== 'pending') {
-    return res.redirect(`${base}/connect?error=` + encodeURIComponent('Verzoek niet gevonden'));
-  }
-  if (decision === 'approve') await ActivityPubService.acceptGatedFollow(pending);
-  else await ActivityPubService.rejectGatedFollow(pending);
-  Guardianship.follows.remove(pending.id);
-  return res.redirect(`${base}/connect?success=` + encodeURIComponent(
-    decision === 'approve' ? 'Volger geaccepteerd' : 'Verzoek geweigerd'));
-});
-
-router.post('/news/follow', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const handle = (req.body.handle || '').toString();
-  let q = 'success=' + encodeURIComponent('Volgverzoek verstuurd');
-  if (site && handle.trim()) {
-    try {
-      const r = await ActivityPubService.followActor(site, handle, !!req.body.auto_boost);
-      // 'moved' is geen mislukking maar een weigering met een reden, en die reden
-      // hoort de gebruiker te lezen. "Volgen mislukt" laat hem zoeken naar een
-      // storing die er niet is.
-      if (r && r.error === 'moved') q = 'error=' + encodeURIComponent(`Dit account is verhuisd naar ${r.movedTo}. Volgen doe je daarvandaan.`);
-      else if (r && r.error) q = 'error=' + encodeURIComponent(r.error === 'not_found' ? 'Account niet gevonden' : (r.error === 'unreachable' ? 'Server onbereikbaar' : 'Volgen mislukt'));
-      // Een DERDE uitkomst, niet gelukt en niet mislukt (shaer-p729). "Je volgt
-      // nu X" zeggen terwijl het verzoek bij de guardians ligt is de leugen die
-      // deze poort waardeloos maakt: het kind denkt dat het gebeurd is.
-      else if (r && r.held) q = 'success=' + encodeURIComponent(r.status === 'denied' ? 'Je guardians hebben dit geweigerd' : 'Je verzoek ligt bij je guardians');
-      else {
-        q = 'success=' + encodeURIComponent('Je volgt nu ' + ((r && r.name) || handle));
-      }
-    } catch (e) { q = 'error=' + encodeURIComponent('Volgen mislukt'); }
-  }
-  res.redirect('/following?' + q);
-});
-
-// ── Je volglijst meenemen ─────────────────────────────────────────
-//
-// Zonder dit was verhuizen halfslachtig: de Move vertelt je VOLGERS waar je heen
-// ging, maar niets vertelde JOU wie jij volgde. Die lijst stond alleen in de
-// database die je achterlaat.
-router.get('/news/following.csv', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const { followingCsv } = await import('../services/ArchiveExportService.js');
-  const csv = site ? followingCsv(site.slug) : null;
-  if (!csv) return res.redirect('/connect?error=' + encodeURIComponent('Je volgt nog niemand'));
-  res.set('Content-Type', 'text/csv; charset=utf-8');
-  res.set('Content-Disposition', `attachment; filename="following-${site.slug}.csv"`);
-  // Privé: dit is de lijst van wie jij volgt, niets voor een cache onderweg.
-  res.set('Cache-Control', 'private, no-store');
-  res.send(csv);
-});
-
-// Een bestand OF geplakte tekst. Multer leest een multipart-formulier, en dat
-// bevat allebei: het bestandsveld en het tekstveld. In het geheugen, niet op
-// schijf: dit is een lijstje adressen van een paar kilobyte dat na het lezen
-// niets meer te zoeken heeft op de server.
-const followingCsvUpload = multer({
-  storage: multer.memoryStorage(),
-  limits: { fileSize: 512 * 1024, files: 1 },
-}).single('csvfile');
-
-router.post('/news/following/import', requireSiteManager, followingCsvUpload, async (req, res) => {
-  const site = res.locals.site;
-  // Een geupload bestand wint van het plakveld: wie een bestand kiest bedoelt dat.
-  const csv = (req.file && req.file.buffer)
-    ? req.file.buffer.toString('utf8').replace(/^﻿/, '')   // BOM eraf; Excel zet die erin
-    : ((req.body && req.body.csv) || '');
-  // Terug naar waar je vandaan kwam. Sinds 14-8 staat dit formulier op
-  // /admin/migrate (Robin: alle migratie-opties bij elkaar); terugspringen naar
-  // Connect is dan desorienterend. Alleen een eigen pad, geen open redirect.
-  const terug = /^\/[A-Za-z0-9/_-]*$/.test(String(req.body.next || '')) ? String(req.body.next) : '/connect';
-  if (!site || !String(csv).trim()) return res.redirect(terug + '?error=' + encodeURIComponent('Geen lijst ontvangen'));
-
-  const { importFollowing } = await import('../services/ArchiveImportService.js');
-  // followActor als followFn: die doet de webfinger, stuurt de Follow en zet
-  // auto_boost meteen goed. Zo blijft er één pad naar een volgrelatie.
-  const r = await importFollowing(site, csv, {
-    followFn: async (s, adres, uitgelicht) => {
-      const uit = await ActivityPubService.followActor(s, adres, !!uitgelicht);
-      // followActor meldt een fout als VELD, niet als exception. Zonder deze
-      // vertaling telde een onvindbaar account gewoon als geslaagd mee.
-      if (uit && uit.error) throw new Error(uit.error);
-      return true;
-    },
-  });
-
-  const delen = [`${r.gevolgd} gevolgd`];
-  if (r.overgeslagen) delen.push(`${r.overgeslagen} overgeslagen`);
-  if (r.mislukt.length) {
-    const namen = r.mislukt.slice(0, 3).map((m) => m.adres).join(', ');
-    delen.push(`${r.mislukt.length} mislukt (${namen}${r.mislukt.length > 3 ? '…' : ''})`);
-  }
-  // Terug naar /connect: daar staat het blok, /following is de oude pagina.
-  res.redirect(terug + '?' + (r.mislukt.length ? 'error=' : 'success=') + encodeURIComponent(delen.join(', ')));
-});
-
-router.post('/news/unfollow', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const actorUri = (req.body.actor_uri || '').toString();
-  if (site && actorUri) { try { await ActivityPubService.unfollowActor(site, actorUri); } catch (e) { /* ignore */ } }
-  res.redirect('/following?success=' + encodeURIComponent('Ontvolgd'));
-});
-
-// Toggle "Featured" (show this account's posts in your Cirkel) on an account you follow.
-router.post('/news/autoboost', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const actorUri = (req.body.actor_uri || '').toString();
-  if (site && actorUri) ActivityPubService.setAutoBoost(site.slug, actorUri, !!req.body.auto_boost);
-  res.redirect('/following?success=' + encodeURIComponent(req.body.auto_boost ? 'Uitgelicht ✨' : 'Niet meer uitgelicht'));
-});
-
-// Like / unlike a feed post — a toggle. Fetch request → JSON {on} (stay on the page,
-// no banner); no-JS → redirect back.
-router.post('/news/like', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const note = (req.body.note || '').toString();
-  let on = false;
-  if (site && note) {
-    on = !ActivityPubService.getReaction(site.slug, note).liked;
-    try { await ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
-    ActivityPubService.setReaction(site.slug, note, 'like', on);
-  }
-  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
-  res.redirect('/news');
-});
-
-// Boost / unboost a feed post — a toggle. markBoosted also surfaces it in the Cirkel.
-router.post('/news/boost', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const note = (req.body.note || '').toString();
-  let on = false;
-  if (site && note) {
-    on = !ActivityPubService.getReaction(site.slug, note).boosted;
-    try { await ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
-    ActivityPubService.setReaction(site.slug, note, 'boost', on); // instant UI state
-    if (on) {
-      // Fire-and-forget: re-resolve the note so the cached row is refreshed
-      // (cover/content) — boosting again heals a stale copy from EVERY boost
-      // path, not just the interact page.
-      ActivityPubService.resolveRemoteNote(note)
-        .then((n) => { if (n) ActivityPubService.setReaction(site.slug, note, 'boost', true, { note: n }); })
-        .catch(() => { /* best-effort */ });
-    }
-  }
-  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
-  res.redirect('/news');
-});
-
-// Vote on a fediverse poll (a Question in the feed). Owner-only, like the other interactions.
-router.post('/news/vote', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const note = (req.body.note || '').toString();
-  let choice = req.body.choice;
-  if (choice == null) choice = [];
-  if (!Array.isArray(choice)) choice = [choice];
-  if (site && note && choice.length) { try { await ActivityPubService.voteOnPoll(site, note, choice.map(String)); } catch (e) { /* ignore */ } }
-  res.redirect('/news');
-});
-
-// Notifications inbox (new followers + replies/likes/boosts on your posts).
-router.get('/notifications', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/messages`));
-
-// Blocking / defederation (owner-only).
-router.get('/blocking', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const blocks = site ? ActivityPubService.listBlocks(site.slug) : [];
-  renderPage(req, res, 'pages/blocks', { pageTitle: 'Blokkeren', bodyClass: 'on-special', blocks, success: req.query.success || null, error: req.query.error || null });
-});
-
-router.post('/blocking/add', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  let q = 'success=' + encodeURIComponent('Geblokkeerd');
-  if (site) {
-    try {
-      const r = await ActivityPubService.blockTarget(site, (req.body.target || '').toString());
-      if (r && r.error) q = 'error=' + encodeURIComponent(r.error === 'not_found' ? 'Account niet gevonden' : 'Voer een @handle of domein in');
-      else q = 'success=' + encodeURIComponent(((r && r.label) || '') + ' geblokkeerd');
-    } catch (e) { q = 'error=' + encodeURIComponent('Blokkeren mislukt'); }
-  }
-  const ref = req.get('Referer') || '';
-  res.redirect((ref.includes('/news') ? '/news?' : '/blocking?') + q);
-});
-
-router.post('/blocking/remove', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  if (site) { try { ActivityPubService.unblock(site, (req.body.target || '').toString()).catch(() => {}); } catch (e) { /* ignore */ } }
-  res.redirect('/blocking?success=' + encodeURIComponent('Deblokkeerd'));
-});
-
-// ==================== VIEW POST (last route â€” catches /:slug) ====================
-router.get('/:slug', (req, res, next) => {
-  if (RESERVED_SLUGS.has(req.params.slug)) return next();
-
-  const site = res.locals.site;
-  if (!site) return next(); // -> nette 404 catch-all
-
-  const post = db.prepare(`
-    SELECT p.*, u.username as author_username, u.avatar_url as author_avatar
-    FROM posts p JOIN users u ON p.author_id = u.id
-    WHERE p.site_id = ? AND p.slug = ?
-  `).get(site.id, req.params.slug);
-
-  if (!post) return next(); // unknown slug -> clean 404 catch-all
-
-  // Permission to view: published OR (logged in + can edit)
-  if (post.status !== 'published') {
-    const canEdit = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
-    if (!canEdit) return res.status(403).send('Not published');
-  }
-
-  // Paid gate (klonkt-demo-aki): a paid post shows only a teaser to anyone who
-  // is not the owner/editor. Checked BEFORE the fan gate: a post that is both
-  // fan_only and paid unlocks with a passkey, not with a Klonkt-login, so the
-  // paid gate wins (otherwise anonymous visitors land on the login gate and
-  // never see the unlock button).
-  const canEditThis = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
-  // A fresh unlock capability (?u=) from /paid/unlock lets a just-verified
-  // supporter render the FULL post through this normal template (correct layout,
-  // scoped styles, working audio). Short-lived signed blob, single post, not a
-  // cookie and not stored.
-  const _u = req.query.u ? verifyBlob(String(req.query.u)) : null;
-  const _unlocked = _u && _u.purpose === 'unlocked' && _u.siteId === site.id && String(_u.post) === String(post.slug);
-  if (post.paid && !canEditThis && !_unlocked) {
-    const { newerPost, olderPost } = postNeighbors(site, post);
-    const pgAudio = paidOpenAudioHtml(site, post, req);
-    return renderPage(req, res, 'pages/paid-gate', {
-    pageJs: 'paid-gate' + (pgAudio ? ' tape' : ''),
-      pageTitle: post.title || 'Voor supporters',
-      bodyClass: 'on-special',
-      pgTitle: post.title || '',
-      pgTeaser: paidTeaser(post),
-      pgAudio,
-      pgCents: post.paid_min_cents || paidDefaultMinCents(site.id),
-      pgSlug: post.slug,
-      pgPatronUrl: paidPatronUrl(site.id),
-      newerPost,
-      olderPost,
-    });
-  }
-
-  // Fan-only preview (premium #3): full content only for logged-in fans.
-  // Anonymous visitors get a clean login gate instead of the content (the title/
-  // teaser may still appear elsewhere as a teaser).
-  // Een bezoeker die via OpenWebAuth bewees @iemand@ergens te zijn EN deze site
-  // volgt, is precies wie fan_only bedoelde. Die hoeft geen poort te zien.
-  const _fediVolger = OWA.isFollowerOf(site.slug, OWA.guestActor(req));
-  if (post.fan_only && !(req.session && req.session.user) && !_fediVolger) {
-    // Same Newer/Older navigation as on a normal post, so the visitor doesn't get
-    // stuck on the fan gate but can keep browsing.
-    const { newerPost, olderPost } = postNeighbors(site, post);
-    return renderPage(req, res, 'pages/fan-gate', {
-      pageTitle: post.title || 'Alleen voor fans',
-      bodyClass: 'on-special',
-      fgTitle: post.title || '',
-      fgNext: (res.locals.siteUrlBase || '') + '/' + post.slug,
-      owaError: !!(req.query && req.query.owa_error),
-      newerPost,
-      olderPost,
-    });
-  }
-
-  // Statistics: count the view (skips admins + unpublished own-preview).
-  if (post.status === 'published') recordPostView(post, req);
-
-  // Render content. Base = the pre-rendered ("baked") display HTML: #hashtags/URLs (and, later,
-  // @mentions) linkified once at SAVE and cached in content_rendered — the ActivityPub `source`
-  // model (content = raw source, kept for editing). Old posts with no baked copy fall back to
-  // baking on the fly (cheap, no network). The dynamic layer (autoembed + [[track/album/
-  // playlist]] + signed audio URLs) stays per-render on top, since it can't be cached.
-  post.content_html = renderPostBodyHtml(site, post, req);
+  post.content_html = html;
 
   if (post.tags) {
@@ -1653,13 +476,39 @@
   }
 
-  // Native comments removed: social interaction is fediverse-only (see the
-  // "From the fediverse" section below).
+  // Comments: top-level + replies. Two-pass build: fetch all approved
+  // comments for the post, then group replies under their parent.
+  const commentRows = db.prepare(`
+    SELECT c.id, c.parent_comment_id, c.content, c.status, c.created_at,
+           c.author_id, u.username AS author_username, u.avatar_url AS author_avatar
+    FROM comments c JOIN users u ON u.id = c.author_id
+    WHERE c.post_id = ? AND c.status = 'approved'
+    ORDER BY c.created_at ASC
+  `).all(post.id);
+  const topLevel = [];
+  const repliesById = new Map();
+  for (const c of commentRows) {
+    if (c.parent_comment_id) {
+      if (!repliesById.has(c.parent_comment_id)) repliesById.set(c.parent_comment_id, []);
+      repliesById.get(c.parent_comment_id).push(c);
+    } else {
+      topLevel.push(c);
+    }
+  }
+  for (const c of topLevel) c.replies = repliesById.get(c.id) || [];
+  const totalComments = commentRows.length;
 
   // Prev / next chronological (kept for back-compat — "post-nav" feature
   // below the article still uses these as a simple linear navigation).
-  const urlBaseFor = () => '';
-
-  // Newer/Older across ALL posts (shared helper — also used by the fan gate).
-  const { newerPost, olderPost } = postNeighbors(site, post);
+  const prevPost = db.prepare(`
+    SELECT slug, title FROM posts
+    WHERE site_id = ? AND status = 'published' AND published_at < ? AND id != ?
+    ORDER BY published_at DESC LIMIT 1
+  `).get(site.id, post.published_at, post.id);
+
+  const nextPost = db.prepare(`
+    SELECT slug, title FROM posts
+    WHERE site_id = ? AND status = 'published' AND published_at > ? AND id != ?
+    ORDER BY published_at ASC LIMIT 1
+  `).get(site.id, post.published_at, post.id);
 
   // ── Related posts: same-tag matching with recency fallback ─────
@@ -1667,8 +516,9 @@
   // Excluding self via `id != ?`.
   const candidates = db.prepare(`
-    SELECT id, slug, title, cover_image_url, cover_video_url, published_at, tags, nsfw, content_warning
+    SELECT id, slug, title, cover_image_url, published_at, tags
     FROM posts
     WHERE site_id = ? AND status = 'published' AND id != ?
-    ORDER BY published_at DESC LIMIT 50
+    ORDER BY published_at DESC
+    LIMIT 50
   `).all(site.id, post.id);
 
@@ -1707,32 +557,50 @@
   }
   // Strip the internal _overlap field before sending to view
-  relatedPosts = relatedPosts.map(({ _overlap, tags, ...rest }) => ({ ...rest, _urlBase: urlBaseFor(rest) }));
-
-  // Inbound fediverse activity (threaded) for this post.
-  let fediverse = { thread: [], likeCount: 0, announceCount: 0, total: 0 };
-  try {
-    const _apBase = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
-    fediverse = ActivityPubService.getInteractions(post.id, _apBase, site);
-    // Stale-while-revalidate: render from cache now; refresh the remote thread in the
-    // background (TTL-gated, non-blocking) so undelivered replies-to-replies fill in next view.
-    if (res.locals.apEnabled !== false) ActivityPubService.maybeCrawlThread(post.id);
-  } catch { /* non-fatal */ }
-  // Owner/admin of this site may reply back to a fediverse interaction.
-  const canManageSite = !!(req.session?.user && PermissionsService.canAdminSite(req.session.user, site));
-  // Avatar for our own (outbound) fediverse replies = the site's profile photo.
-  const siteAvatar = (site && site.profile_photo) ? site.profile_photo : null;
+  relatedPosts = relatedPosts.map(({ _overlap, tags, ...rest }) => rest);
+
+  // ── Pinned navigation: prev/next pinned post ───────────────────
+  // Only meaningful if the current post is pinned. We order by
+  // published_at DESC (newest pinned first) — same as the homepage feed.
+  // Pinned navigation: prev/next pinned post by RANK (not by date).
+  // - prev (← back to) = post with smaller rank, i.e. higher in stack
+  // - next (→ forward) = post with larger rank, i.e. lower in stack
+  // BOVENAAN appears when current is rank 1 (no rank 0 above);
+  // ONDERAAN appears when current is the highest rank (no further down).
+  let prevPinnedPost = null;
+  let nextPinnedPost = null;
+  let pinnedTopOfStack = false;
+  let pinnedBottomOfStack = false;
+  if (post.pinned > 0) {
+    // The rank one step UP the stack (towards #1)
+    prevPinnedPost = db.prepare(`
+      SELECT slug, title FROM posts
+      WHERE site_id = ? AND status = 'published' AND pinned > 0
+        AND pinned < ? AND id != ?
+      ORDER BY pinned DESC LIMIT 1
+    `).get(site.id, post.pinned, post.id) || null;
+
+    // The rank one step DOWN the stack (away from #1)
+    nextPinnedPost = db.prepare(`
+      SELECT slug, title FROM posts
+      WHERE site_id = ? AND status = 'published' AND pinned > 0
+        AND pinned > ? AND id != ?
+      ORDER BY pinned ASC LIMIT 1
+    `).get(site.id, post.pinned, post.id) || null;
+
+    pinnedTopOfStack    = !prevPinnedPost;  // already rank #1 (or nothing higher)
+    pinnedBottomOfStack = !nextPinnedPost;  // nothing further down the stack
+  }
 
   renderPage(req, res, 'pages/post', {
-    pageJs: 'post reply-editor tape',
     post,
-    poll: ActivityPubService.ownPollView(post),
-    newerPost,
-    olderPost,
+    prevPost,
+    nextPost,
     relatedPosts,
-    fediverse,
-    canManageSite,
-    siteAvatar,
-    postHasPlayableAudio: ActivityPubService.hasPlayableAudio(post.content || '', site.id),
-    musicLd: MusicMeta.build((process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, ''), site, post),
+    prevPinnedPost,
+    nextPinnedPost,
+    pinnedTopOfStack,
+    pinnedBottomOfStack,
+    comments: topLevel,
+    totalComments,
     pageTitle: post.title + ' - ' + site.title,
     socialDescr: post.excerpt || '',
@@ -1742,57 +610,3 @@
 });
 
-// ── Reply back to a fediverse interaction (site owner/admin only) ──
-router.post('/posts/:slug/fedi-reply', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.status(404).send('Site required');
-  const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, req.params.slug);
-  if (!post) return res.status(404).send('Not found');
-  const parent = ActivityPubService.getInteractionById(req.body.interaction_id);
-  const text = (req.body.text || '').toString();
-  const html = (req.body.content || '').toString();      // rich reply editor HTML (sanitized in deliverReply)
-  let attachments = [];
-  try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
-  let mentions;   // undefined = geen balk meegestuurd (legacy addressing)
-  try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
-  if (parent && parent.post_id === post.id && (text.trim() || html.trim() || (Array.isArray(attachments) && attachments.length))) {
-    try {
-      await ActivityPubService.deliverReply(site, {
-        postId: post.id, postSlug: post.slug, parent, text, html, attachments, mentions,
-        language: (req.body.language || '').toString(),
-      });
-    } catch (e) { console.warn('[AP] reply send failed:', e.message); }
-  }
-  res.redirect(`${res.locals.siteUrlBase || ''}/${post.slug}#fediverse`);
-});
-
-// Owner likes/boosts a fediverse comment on their own post — directly as the
-// site, no "your server" detour (mirrors /fedi-reply).
-router.post('/posts/:slug/fedi-react', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.status(404).send('Site required');
-  const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, req.params.slug);
-  if (!post) return res.status(404).send('Not found');
-  const parent = ActivityPubService.getInteractionById(req.body.interaction_id);
-  const kind = req.body.kind === 'boost' ? 'boost' : 'like';
-  if (parent && parent.post_id === post.id && parent.object_uri) {
-    // Toggle: react, or retract it (Undo Announce / Undo Like) if already on.
-    // De stand komt uit dezelfde bron als de knop die je zag; leest de toggle uit
-    // de kolom en de knop uit de tussentabel, dan draait een divergentie de
-    // richting om en stuur je een Undo voor iets dat nooit is verstuurd.
-    const ik = ActivityPubService.getReaction(site.slug, parent.object_uri);
-    const on = kind === 'boost' ? !ik.boosted : !ik.liked;
-    ActivityPubService.sendInteraction(site, on ? kind : `un${kind}`, parent.object_uri, parent.actor_uri)
-      .catch((e) => console.warn('[AP] reaction failed:', e.message));
-    // De tussentabel is de waarheid (shaer-ipb), gesleuteld op object_uri -- net
-    // als de Like die hierboven de fediverse in gaat. acted_* blijft voorlopig
-    // als afgeleide meelopen, hetzelfde vangnet dat ap_timeline.liked na
-    // shaer-9e9 is: pas weghalen als deze migratie een release heeft ingelopen.
-    ActivityPubService.setReaction(site.slug, parent.object_uri, kind, on);
-    if (kind === 'boost') ActivityPubService.setInteractionBoosted(parent.id, on);
-    else ActivityPubService.setInteractionLiked(parent.id, on);
-  }
-  res.redirect(`${res.locals.siteUrlBase || ''}/${post.slug}#fediverse`);
-});
-
 export default router;
-export { postNeighbors };
Index: src/routes/prutter.js
===================================================================
--- src/routes/prutter.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
+++ src/routes/prutter.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -0,0 +1,137 @@
+/**
+ * Prutter — Direct Messaging routes.
+ *
+ * GET  /prutter                   -> inbox (list of your conversations on THIS site)
+ * GET  /prutter/new?to=<username> -> start (or resume) a conversation, redirects to /prutter/:id
+ * GET  /prutter/:id               -> conversation view (messages + send form)
+ * POST /prutter/:id/send          -> send a message (HTMX-friendly response)
+ *
+ * Scoping: conversations are per-site (PrutterService.getOrCreateConversation
+ * uses res.locals.site.id). Robin's quote: "Prutter = DM (per .com domain)".
+ *
+ * Per-site toggle: site.enable_prutter == 0 -> 404 the whole feature.
+ *
+ * No anonymous DMs — always requires login.
+ */
+
+import express from 'express';
+import db from '../config/database.js';
+import { renderPage } from '../middleware/render.js';
+import { requireAuth } from '../middleware/auth.js';
+import PermissionsService from '../services/PermissionsService.js';
+
+const router = express.Router();
+
+const MAX_MESSAGE_LEN = 2000;
+
+function siteAllowsDM(req, res) {
+  const site = res.locals.site;
+  if (!site) return false;
+  return site.enable_prutter !== 0;
+}
+
+// Middleware: require both auth + Prutter enabled
+function requirePrutter(req, res, next) {
+  if (!siteAllowsDM(req, res)) return res.status(404).send('Prutter not enabled on this site');
+  return requireAuth(req, res, next);
+}
+
+// ==================== INBOX ====================
+router.get('/', requirePrutter, (req, res) => {
+  const prutter = req.app.locals.prutter;
+  const conversations = prutter.getUserConversations(req.session.user.id);
+
+  // Filter to only conversations on THIS site (Prutter scope is per-site)
+  const siteId = res.locals.site.id;
+  const scoped = conversations.filter(c => c.site_id === siteId);
+
+  renderPage(req, res, 'pages/prutter-inbox', {
+    pageTitle: 'Prutter',
+    bodyClass: 'on-special',
+    conversations: scoped,
+  });
+});
+
+// ==================== START / RESUME CONVERSATION ====================
+router.get('/new', requirePrutter, (req, res) => {
+  const targetUsername = (req.query.to || '').toString().trim();
+  if (!targetUsername) {
+    return res.redirect(`${res.locals.siteUrlBase || ''}/prutter`);
+  }
+  const target = db.prepare('SELECT id, username FROM users WHERE username = ?').get(targetUsername);
+  if (!target) return res.status(404).send('User not found');
+  if (target.id === req.session.user.id) {
+    return res.redirect(`${res.locals.siteUrlBase || ''}/prutter`);
+  }
+
+  const prutter = req.app.locals.prutter;
+  const conv = prutter.getOrCreateConversation(req.session.user.id, target.id, res.locals.site.id);
+  res.redirect(`${res.locals.siteUrlBase || ''}/prutter/${conv.id}`);
+});
+
+// ==================== CONVERSATION VIEW ====================
+router.get('/:id', requirePrutter, (req, res) => {
+  const prutter = req.app.locals.prutter;
+  const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(req.params.id);
+  if (!conv) return res.status(404).send('Conversation not found');
+
+  // Auth: must be a participant
+  const me = req.session.user.id;
+  if (conv.user_a_id !== me && conv.user_b_id !== me) return res.status(403).send('Not a participant');
+
+  // Scope: this conversation must belong to the resolved site
+  if (conv.site_id !== res.locals.site.id) return res.status(404).send('Conversation not on this site');
+
+  // Other party
+  const otherId = conv.user_a_id === me ? conv.user_b_id : conv.user_a_id;
+  const other = db.prepare('SELECT id, username, avatar_url FROM users WHERE id = ?').get(otherId);
+
+  // Messages (oldest first for natural reading order)
+  const messages = prutter.getMessages(conv.id, 200, 0).reverse();
+
+  // Mark inbound messages as read
+  prutter.markAsRead(conv.id, me);
+
+  renderPage(req, res, 'pages/prutter-conversation', {
+    pageTitle: 'Prutter — ' + (other?.username || ''),
+    bodyClass: 'on-special',
+    conversation: conv,
+    other,
+    messages,
+  });
+});
+
+// ==================== SEND MESSAGE ====================
+router.post('/:id/send', requirePrutter, (req, res) => {
+  const prutter = req.app.locals.prutter;
+  const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(req.params.id);
+  if (!conv) return res.status(404).send('Not found');
+
+  const me = req.session.user.id;
+  if (conv.user_a_id !== me && conv.user_b_id !== me) return res.status(403).send('Not a participant');
+  if (conv.site_id !== res.locals.site.id) return res.status(404).send('Wrong site');
+
+  const content = (req.body.content || '').toString().trim();
+  if (!content) return res.status(400).send('Empty');
+  if (content.length > MAX_MESSAGE_LEN) return res.status(413).send('Too long');
+
+  const message = prutter.sendMessage(conv.id, me, content);
+
+  // HTMX request → return the single rendered message HTML, appended to the thread
+  if (req.headers['hx-request']) {
+    return res.send(
+      `<li class="prutter-msg prutter-msg--mine" data-msg-id="${message.id}">` +
+      `<div class="prutter-msg-bubble">${escapeHtml(content)}</div>` +
+      `</li>`
+    );
+  }
+
+  res.redirect(`${res.locals.siteUrlBase || ''}/prutter/${conv.id}`);
+});
+
+function escapeHtml(s) {
+  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
+          .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
+}
+
+export default router;
Index: src/routes/push.js
===================================================================
--- src/routes/push.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,66 +1,0 @@
-/**
- * Web Push (docs/webpush-design.md) slice 2: enable/disable + test.
- * The public VAPID key is public by design (it only identifies this server to
- * the browser's push service); everything that touches a subscription is a
- * logged-in action. Web Push delivery itself is cookie-less.
- */
-import express from 'express';
-import db from '../config/database.js';
-import { requireAuth } from '../middleware/auth.js';
-import Push from '../services/PushService.js';
-import { t as i18nT, resolveLang } from '../services/i18n.js';
-
-const router = express.Router();
-
-// A subscription row is personal: only its creator may touch it.
-function ownRow(endpoint, userId) {
-  if (!endpoint) return null;
-  const row = db.prepare('SELECT endpoint, user_id FROM push_subscriptions WHERE endpoint = ?').get(String(endpoint));
-  return row && row.user_id === userId ? row : null;
-}
-
-router.get('/vapid', async (req, res) => {
-  const key = await Push.publicKey();
-  if (!key) return res.status(503).json({ error: 'push_unavailable' });
-  res.json({ publicKey: key });
-});
-
-router.post('/subscribe', requireAuth, express.json({ limit: '16kb' }), async (req, res) => {
-  if (!(await Push.pushReady())) return res.status(503).json({ error: 'push_unavailable' });
-  const s = req.body && req.body.subscription;
-  const keys = s && s.keys;
-  const ok = Push.saveSubscription({
-    endpoint: s && s.endpoint, userId: req.session.user.id,
-    p256dh: keys && keys.p256dh, auth: keys && keys.auth,
-    alertTypes: req.body.alerts || null,
-    uaLabel: String(req.body.uaLabel || '').slice(0, 120) || null,
-  });
-  if (!ok) return res.status(400).json({ error: 'bad_subscription' });
-  res.json({ ok: true });
-});
-
-router.post('/unsubscribe', requireAuth, express.json({ limit: '4kb' }), (req, res) => {
-  const row = ownRow(req.body && req.body.endpoint, req.session.user.id);
-  if (!row) return res.status(404).json({ error: 'not_found' });
-  Push.deleteSubscription(row.endpoint);
-  res.json({ ok: true });
-});
-
-router.post('/alerts', requireAuth, express.json({ limit: '4kb' }), (req, res) => {
-  const row = ownRow(req.body && req.body.endpoint, req.session.user.id);
-  if (!row) return res.status(404).json({ error: 'not_found' });
-  Push.updateAlerts(row.endpoint, req.session.user.id, req.body.alerts || {});
-  res.json({ ok: true });
-});
-
-// A test ping to all of the caller's own devices (bypasses alert prefs).
-router.post('/test', requireAuth, async (req, res) => {
-  const L = resolveLang(req);
-  const sent = await Push.notifyUser(req.session.user.id, {
-    type: 'test', title: i18nT(L, 'push.n_test_t'),
-    body: i18nT(L, 'push.n_test_b'), url: '/admin/push',
-  });
-  res.json({ ok: true, sent });
-});
-
-export default router;
Index: src/routes/search.js
===================================================================
--- src/routes/search.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/routes/search.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,15 +1,14 @@
 /**
- * GET /search?q=...          -> full results page
- * GET /search/suggest?q=...  -> compact JSON for live results in the overlay
+ * GET /search?q=...
  *
- * Searches the current site across:
- *   1. Posts via posts_fts (FTS5, prefix-matching) — published only.
- *   2. Tracks (audio_tracks) on title / artist / album.
- *   3. Events (shows) on city / venue / country / notes — when the agenda is enabled.
- *   4. Pages (Agenda / Downloads / Links / Press kit / Archive) by name — only
- *      the available ones.
+ * Queries the posts_fts virtual table (FTS5) for the current site.
+ * Search is restricted to published posts of the resolved site.
  *
- * FTS5: user input is tokenised on non-letter/digit chars and each token is wrapped
- * in double quotes + `*` → prefix-match, no operator-soup/syntax-errors.
+ * FTS5 quirks handled:
+ *   - Empty / whitespace-only query: render the form with no results.
+ *   - User input is wrapped in double quotes so FTS5 treats it as a phrase
+ *     (avoids syntax errors from special chars like "OR", parentheses, etc.).
+ *   - Snippet() builds the highlighted excerpt; we keep markup minimal so
+ *     the EJS view can wrap the matches in <mark>.
  */
 
@@ -17,165 +16,68 @@
 import db from '../config/database.js';
 import { renderPage } from '../middleware/render.js';
-import { audioUrl } from '../services/AudioStreamService.js';
-import { getSetting } from '../services/SettingsService.js';
-import { premiumUnlocked } from '../services/PatreonService.js';
-import { t as i18nT, resolveLang } from '../services/i18n.js';
 
 const router = express.Router();
 
-function buildFtsQuery(q) {
-  const terms = q.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
-  if (!terms.length) return null;
-  return terms.map((t) => '"' + t + '"*').join(' ');
+// Wrap user input as a single FTS5 phrase. Strip embedded double-quotes so
+// the wrapping stays balanced. FTS5 phrase queries are forgiving and avoid
+// the operator-soup pitfalls of bare user input.
+function asPhrase(q) {
+  return '"' + q.replace(/"/g, '') + '"';
 }
 
-function likeArg(q) {
-  return '%' + q.replace(/[%_\\]/g, '\\$&') + '%';
-}
-
-function cleanSnippet(html, excerpt) {
-  const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
-  const s = (html || '')
-    .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
-    .replace(/\[\[[^\]]*?\]\]/g, ' ')
-    .replace(/\[\[|\]\]/g, ' ')
-    .replace(/[#>*_`~]+/g, ' ')
-    .replace(/\s+/g, ' ')
-    .trim();
-  if (!s || /^[…\s]*$/.test(s)) return esc((excerpt || '').slice(0, 160));
-  return s;
-}
-
-// ── Core: search all sources for one site. `lim` caps results per group
-//    (small for live suggestions, large for the full page). ──────────────────
-function searchSite(req, res, rawQ, lim) {
-  const site = res.locals.site;
-  const base = res.locals.siteUrlBase || '';
-  const urlFor = (slug) => `/${slug}`;
-  // Eigen vertaler (werkt ook in de JSON-route, waar res.locals.t niet bestaat).
-  const lang = resolveLang(req);
-  const t = (k) => i18nT(lang, k);
-
-  const out = { results: [], tracks: [], events: [], pages: [], queryError: null };
-  if (!site || !rawQ) return out;
-  const like = likeArg(rawQ);
-
-  // 1. Posts (FTS5)
-  const ftsQuery = buildFtsQuery(rawQ);
-  if (ftsQuery) {
-    try {
-      out.results = db.prepare(`
-        SELECT p.slug, p.title, p.excerpt, p.published_at, u.username AS author_username,
-               snippet(posts_fts, 0, '<mark>', '</mark>', '…', 18) AS snippet, bm25(posts_fts) AS score
-        FROM posts_fts
-        JOIN posts p ON p.id = posts_fts.post_id
-        JOIN users u ON u.id = p.author_id
-        WHERE posts_fts MATCH ? AND p.site_id = ? AND p.status = 'published'
-        ORDER BY score ASC LIMIT ?
-      `).all(ftsQuery, site.id, lim.posts);
-      out.results = out.results.map((r) => ({ ...r, snippet: cleanSnippet(r.snippet, r.excerpt) }));
-    } catch (err) { out.queryError = err.message; }
-  }
-
-  // 2. Tracks
-  try {
-    const trackRows = db.prepare(`
-      SELECT t.id, t.title, t.artist, t.album, t.cover_url, t.play_count, m.filename
-      FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
-      WHERE t.site_id = @site
-        AND ( t.title LIKE @like ESCAPE '\\' OR t.artist LIKE @like ESCAPE '\\' OR t.album LIKE @like ESCAPE '\\' )
-      ORDER BY t.play_count DESC, t.title ASC LIMIT @lim
-    `).all({ site: site.id, like, lim: lim.tracks });
-    const playable = trackRows.filter((t) => t.filename);
-    let posts = [];
-    if (playable.length) {
-      posts = db.prepare("SELECT slug, content FROM posts WHERE site_id = ? AND status = 'published' ORDER BY published_at DESC").all(site.id);
-    }
-    const postUrlForTrack = (tr) => {
-      let hit = posts.find((p) => p.content && p.content.includes('[[track:' + tr.id + ']]'));
-      if (!hit && tr.album) hit = posts.find((p) => p.content && p.content.includes('[[album:' + tr.album + ']]'));
-      if (!hit) {
-        const plids = db.prepare('SELECT playlist_id FROM playlist_tracks WHERE track_id = ?').all(tr.id).map((r) => r.playlist_id);
-        if (plids.length) hit = posts.find((p) => p.content && plids.some((pl) => p.content.includes('[[playlist:' + pl + ']]')));
-      }
-      return hit ? urlFor(hit.slug) : null;
-    };
-    out.tracks = playable.map((tr) => ({
-      id: tr.id, title: tr.title || 'Untitled', artist: tr.artist || '', album: tr.album || '',
-      cover: tr.cover_url || '', url: audioUrl(tr.filename), postUrl: postUrlForTrack(tr),
-    }));
-  } catch (err) { if (!out.queryError) out.queryError = err.message; }
-
-  // 3. Events (agenda) — only when the agenda is publicly enabled.
-  if (premiumUnlocked() && getSetting('agenda_enabled') === '1') {
-    try {
-      out.events = db.prepare(`
-        SELECT date, time, city, venue, country FROM shows
-        WHERE site_id = @site
-          AND ( city LIKE @like ESCAPE '\\' OR venue LIKE @like ESCAPE '\\'
-             OR country LIKE @like ESCAPE '\\' OR notes LIKE @like ESCAPE '\\' OR date LIKE @like ESCAPE '\\' )
-        ORDER BY date ASC LIMIT @lim
-      `).all({ site: site.id, like, lim: lim.events }).map((e) => ({
-        date: e.date, time: e.time || '',
-        where: [e.venue, e.city, e.country].filter(Boolean).join(', '),
-        url: base + '/shows',
-      }));
-    } catch (err) { if (!out.queryError) out.queryError = err.message; }
-  }
-
-  // 4. Pages — curated, available ones only; matched against the (translated) name.
-  const ql = rawQ.toLowerCase();
-  const candidates = [
-    { key: 'search.page_agenda', url: base + '/shows', on: premiumUnlocked() && getSetting('agenda_enabled') === '1' },
-    { key: 'search.page_downloads', url: base + '/downloads', on: premiumUnlocked() },
-    { key: 'search.page_links', url: base + '/links', on: premiumUnlocked() },
-    { key: 'search.page_perskit', url: base + '/pers', on: premiumUnlocked() },
-    { key: 'search.page_archive', url: urlFor('archive'), on: !site || site.show_archive_link === undefined || site.show_archive_link },
-  ];
-  out.pages = candidates
-    .filter((c) => c.on)
-    .map((c) => ({ label: t(c.key), url: c.url }))
-    .filter((c) => c.label.toLowerCase().includes(ql))
-    .slice(0, lim.pages);
-
-  return out;
-}
-
-// ── Full results page ────────────────────────────────────────────────────────
 router.get('/', (req, res) => {
   const site = res.locals.site;
-  if (!site) return res.status(404).send('No site');
   const rawQ = (req.query.q || '').toString().trim();
 
+  if (!site) return res.status(404).send('No site');
+
+  // Empty query — render the page with the form and no results.
   if (!rawQ) {
     return renderPage(req, res, 'pages/search', {
-      pageTitle: 'Zoeken', bodyClass: 'on-special', query: '',
-      results: [], tracks: [], events: [], pages: [], total: 0,
+      pageTitle: 'Search',
+      bodyClass: 'on-special',
+      query: '',
+      results: [],
+      total: 0,
     });
   }
 
-  const r = searchSite(req, res, rawQ, { posts: 50, tracks: 25, events: 25, pages: 8 });
-  const total = r.results.length + r.tracks.length + r.events.length + r.pages.length;
+  let results = [];
+  let total = 0;
+  let queryError = null;
+
+  try {
+    // FTS5 join → posts table, filter by site + published
+    results = db.prepare(`
+      SELECT
+        p.slug,
+        p.title,
+        p.excerpt,
+        p.published_at,
+        u.username AS author_username,
+        snippet(posts_fts, 0, '<mark>', '</mark>', '…', 18) AS snippet,
+        bm25(posts_fts) AS score
+      FROM posts_fts
+      JOIN posts p ON p.id = posts_fts.post_id
+      JOIN users u ON u.id = p.author_id
+      WHERE posts_fts MATCH ?
+        AND p.site_id = ?
+        AND p.status = 'published'
+      ORDER BY score ASC
+      LIMIT 50
+    `).all(asPhrase(rawQ), site.id);
+
+    total = results.length;
+  } catch (err) {
+    queryError = err.message;
+  }
+
   renderPage(req, res, 'pages/search', {
-    pageTitle: `Zoeken: ${rawQ}`, bodyClass: 'on-special', query: rawQ,
-    results: r.results, tracks: r.tracks, events: r.events, pages: r.pages,
-    total, queryError: r.queryError,
-  });
-});
-
-// ── Live suggestions (JSON) ──────────────────────────────────────────────────
-router.get('/suggest', (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.json({ posts: [], tracks: [], events: [], pages: [] });
-  const rawQ = (req.query.q || '').toString().trim().slice(0, 100);
-  if (rawQ.length < 2) return res.json({ posts: [], tracks: [], events: [], pages: [] });
-
-  const urlFor = (slug) => `/${slug}`;
-  const r = searchSite(req, res, rawQ, { posts: 5, tracks: 4, events: 3, pages: 4 });
-  res.json({
-    posts: r.results.map((p) => ({ title: p.title || '(zonder titel)', url: urlFor(p.slug) })),
-    tracks: r.tracks.map((tr) => ({ title: tr.title, artist: tr.artist, url: tr.postUrl })),
-    events: r.events.map((e) => ({ when: [e.date, e.time].filter(Boolean).join(' '), where: e.where, url: e.url })),
-    pages: r.pages,
+    pageTitle: `Search: ${rawQ}`,
+    bodyClass: 'on-special',
+    query: rawQ,
+    results,
+    total,
+    queryError,
   });
 });
Index: src/routes/shows.js
===================================================================
--- src/routes/shows.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,77 +1,0 @@
-/**
- * Show agenda + notify-me (premium feature #8) — public side.
- *
- *   GET  /shows         -> upcoming gigs + "keep me posted" form
- *   POST /shows/notify  -> subscribe to show announcements (subscribers, source
- *                          'notify'; double opt-in if SMTP configured)
- *
- * The notify confirm/unsubscribe reuses the generic subscriber links
- * (/nieuwsbrief/bevestigen|uitschrijven/:token). Hub: /user/:slug/shows.
- */
-
-import express from 'express';
-import db from '../config/database.js';
-import { renderPage } from '../middleware/render.js';
-import { premiumUnlocked } from '../services/PatreonService.js';
-import { mailerConfigured, sendMail } from '../config/mailer.js';
-import { addSubscriber } from '../services/SubscriberService.js';
-import { getSetting } from '../services/SettingsService.js';
-
-const router = express.Router();
-
-// Agenda is opt-in: only accessible once the admin has enabled it.
-function agendaOn() { return getSetting('agenda_enabled') === '1'; }
-
-function esc(s) { return String(s || '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
-function fullUrl(req, p) {
-  const base = (process.env.PUBLIC_BASE_URL || ('https://' + (req.get('host') || ''))).replace(/\/$/, '');
-  return base + (req.res.locals.siteUrlBase || '') + p;
-}
-function upcoming(siteId) {
-  const today = new Date().toISOString().slice(0, 10);
-  return db.prepare('SELECT * FROM shows WHERE site_id = ? AND date >= ? ORDER BY date ASC, time ASC').all(siteId, today);
-}
-
-router.get('/shows', (req, res, next) => {
-  if (!premiumUnlocked() || !agendaOn()) return next();
-  const site = res.locals.site;
-  if (!site) return next();
-  renderPage(req, res, 'pages/shows', {
-    pageTitle: 'Agenda — ' + (site.title || ''),
-    bodyClass: 'on-shows',
-    shows: upcoming(site.id),
-    notifyState: req.query.ok ? 'done' : (req.query.check ? 'check' : null),
-  });
-});
-
-router.post('/shows/notify', async (req, res, next) => {
-  if (!premiumUnlocked() || !agendaOn()) return next();
-  const site = res.locals.site;
-  if (!site) return next();
-  const email = (req.body.email || '').trim();
-  const doubleOptin = mailerConfigured();
-  const r = addSubscriber(site.id, email, 'notify', { doubleOptin });
-  if (!r.ok) {
-    return renderPage(req, res, 'pages/shows', {
-      pageTitle: 'Agenda', bodyClass: 'on-shows', shows: upcoming(site.id),
-      notifyState: 'error', notifyMsg: r.error === 'invalid_email' ? 'Controleer je e-mailadres.' : 'Er ging iets mis.',
-    });
-  }
-  if (r.status === 'pending') {
-    const link = fullUrl(req, '/nieuwsbrief/bevestigen/' + r.token);
-    const unsub = fullUrl(req, '/nieuwsbrief/uitschrijven/' + r.token);
-    try {
-      await sendMail({
-        to: email,
-        subject: 'Bevestig — show-updates van ' + (site.title || ''),
-        text: 'Bevestig dat je show-aankondigingen wilt ontvangen: ' + link + '\n\nUitschrijven: ' + unsub,
-        html: '<p>Bevestig dat je show-aankondigingen van <strong>' + esc(site.title) + '</strong> wilt ontvangen:</p>' +
-              '<p><a href="' + link + '">Bevestigen</a></p><p style="color:#888;font-size:12px"><a href="' + unsub + '">Uitschrijven</a></p>',
-      });
-    } catch { return res.redirect((res.locals.siteUrlBase || '') + '/shows'); }
-    return res.redirect((res.locals.siteUrlBase || '') + '/shows?check=1');
-  }
-  res.redirect((res.locals.siteUrlBase || '') + '/shows?ok=1');
-});
-
-export default router;
Index: src/routes/tags.js
===================================================================
--- src/routes/tags.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/routes/tags.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -22,5 +22,5 @@
   try {
     posts = db.prepare(`
-      SELECT DISTINCT p.id, p.slug, p.title, p.excerpt, p.cover_image_url, p.cover_video_url,
+      SELECT DISTINCT p.id, p.slug, p.title, p.excerpt, p.cover_image_url,
                       p.published_at, u.username AS author_username
       FROM posts p, json_each(p.tags) j
@@ -35,5 +35,5 @@
     // Fall back to a LIKE match if json_each isn't available for some reason
     posts = db.prepare(`
-      SELECT p.id, p.slug, p.title, p.excerpt, p.cover_image_url, p.cover_video_url,
+      SELECT p.id, p.slug, p.title, p.excerpt, p.cover_image_url,
              p.published_at, u.username AS author_username
       FROM posts p JOIN users u ON u.id = p.author_id
Index: src/routes/types.js
===================================================================
--- src/routes/types.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/routes/types.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -8,10 +8,8 @@
 import express from 'express';
 import db from '../config/database.js';
-import { POST_TYPES } from '../config/post-types.js';
 import { renderPage } from '../middleware/render.js';
 
 const router = express.Router();
-// Een lijst, drie gebruikers: zie config/post-types.js.
-const VALID_TYPES = POST_TYPES;
+const VALID_TYPES = new Set(['post', 'foto', 'video', 'audio']);
 
 router.get('/:type', (req, res) => {
@@ -23,5 +21,5 @@
 
   const posts = db.prepare(`
-    SELECT p.id, p.slug, p.title, p.excerpt, p.cover_image_url, p.cover_video_url,
+    SELECT p.id, p.slug, p.title, p.excerpt, p.cover_image_url,
            p.published_at, p.type, u.username AS author_username
     FROM posts p JOIN users u ON u.id = p.author_id
Index: src/routes/users.js
===================================================================
--- src/routes/users.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/routes/users.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -27,5 +27,5 @@
 
   const posts = db.prepare(`
-    SELECT p.id, p.slug, p.title, p.excerpt, p.cover_image_url, p.cover_video_url, p.published_at
+    SELECT p.id, p.slug, p.title, p.excerpt, p.cover_image_url, p.published_at
     FROM posts p
     WHERE p.author_id = ? AND p.site_id = ? AND p.status = 'published'
