Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,4350 +1,0 @@
-/**
- * ActivityPubService — Klonkt as a real ActivityPub actor (fediverse bridge).
- *
- * Phase 1 (this file): the PUBLISH/discoverable side.
- *   - per-site RSA keypair (Mastodon-compatible HTTP Signatures; separate from
- *     the Ed25519 keys used by the lighter Cirkels v1)
- *   - builders for the Actor document, Note objects and the Outbox collection
- *   - apWants(): HTTP content-negotiation helper (activity+json vs HTML)
- *
- * The interactive side (inbox: Follow/Accept, signature verify, delivery to
- * followers) lands in the next step and is tested live against Mastodon.
- *
- * AP actor URLs live under /ap/* so they never clash with the human pages:
- *   actor   = <base>/ap/users/<slug>
- *   inbox   = <actor>/inbox      outbox = <actor>/outbox
- *   note    = <base>/ap/notes/<postId>
- */
-import crypto from 'crypto';
-import fs from 'fs';
-import path from 'path';
-import db, { NU_ISO, isoSql } from '../config/database.js';
-import HtmlSanitizerService from './HtmlSanitizerService.js';
-import AudioEmbedService from './AudioEmbedService.js';
-import EmbedResolver from './EmbedResolver.js';
-import Push from './PushService.js';
-import { t as i18nT } from './i18n.js';
-import Blocklist from './BlocklistService.js';
-import * as Guardianship from './guardianship/index.js';
-import { PUBLIC, AP_CONTEXT, safeUrl, actorId, noteId, guessMediaType, normalizeTags, tagParts, hashtagTags, buildHashtagList, pagedCollection, PAGINA_GROOTTE, artiestUrl } from './ap-core.js';
-// Stap 3 van de opsplitsing (shaer-drc): het transport -- de SSRF-poort, de
-// sleutels, HTTP Signatures, de bezorging met wachtrij en de ondertekende
-// GET -- woont in ap-transport.js. Hier her-geëxporteerd zodat elke bestaande
-// importeur blijft werken, hetzelfde patroon als de Guardianship-exports onderaan.
-import {
-  safeFetch, getOrCreateKeys, deliver, fetchActor,
-  enqueueDelivery, deliverWithRetry, processDeliveryQueue, startDeliveryWorker,
-  anySigningSlug, verifyRequest, signedGetHeaders, signedGetJson, apGetJson,
-} from './ap-transport.js';
-export {
-  safeFetch, getOrCreateKeys, deliver, fetchActor,
-  enqueueDelivery, deliverWithRetry, processDeliveryQueue, startDeliveryWorker,
-  verifyRequest, signedGetHeaders, signedGetJson,
-};
-// Stap 4 (shaer-drc): de C2S-inname woont in ap-c2s.js. Die is een coordinator
-// en krijgt zijn werktuigen uit de dienstlaag onderaan dit bestand via
-// wireC2S -- de regel blijft dat een module NOOIT uit dit bestand importeert.
-import { ingestOutboxActivity, wireC2S } from './ap-c2s.js';
-export { ingestOutboxActivity };
-// Stap 5 (shaer-drc): de leeskant van de tijdlijn woont in ap-timeline.js.
-// tlStmts komt mee terug omdat de SCHRIJVERS (inbox, backfill, self-heal,
-// upsertBoostedNote) hier wonen; wireTimeline krijgt onderaan zijn ene
-// werktuig uit het reactiecluster.
-import {
-  tlStmts, wireTimeline,
-  getTimeline, replyRowsByUri, timelineRowsByIds, getReplyMessages,
-  feedCursor, feedChangesSince, waitForFeedChange,
-  conversationHeads, conversationHistory, messageRowsByUri,
-  readMarkers, markRead, unreadPerConversation, getDirectMessages,
-  isoStamp, timelineAttachments, extractEmojiTags, gateAttachments,
-  stripEmojiTags, timelineEmojis, extractObjectLinkTags, timelineObjectLinks,
-  extractQuoteUrl, extractLinkJson, quoteHrefOf, timelineQuote,
-} from './ap-timeline.js';
-export {
-  getTimeline, replyRowsByUri, timelineRowsByIds, getReplyMessages,
-  feedCursor, feedChangesSince, waitForFeedChange,
-  conversationHeads, conversationHistory, messageRowsByUri,
-  readMarkers, markRead, unreadPerConversation, getDirectMessages,
-  isoStamp, timelineAttachments, extractEmojiTags, gateAttachments,
-  stripEmojiTags, timelineEmojis, extractObjectLinkTags, timelineObjectLinks,
-  extractQuoteUrl, extractLinkJson, quoteHrefOf, timelineQuote,
-};
-// Stap 6 (shaer-drc): het reactiecluster woont in ap-reactions.js. Dat
-// importeert tlStmts zelf statisch uit ap-timeline; alleen movedLock gaat er
-// onderaan via wireReactions in.
-import {
-  wireReactions,
-  setMyReaction, getMyReactions,
-  markBoosted, unmarkBoosted, markLiked, unmarkLiked,
-  migrateReactions, canonicalReactionUri, getReaction, getReactionsFor,
-  setReaction, getTimelineReaction, upsertBoostedNote, boostedCount,
-} from './ap-reactions.js';
-export {
-  setMyReaction, getMyReactions,
-  markBoosted, unmarkBoosted, markLiked, unmarkLiked,
-  migrateReactions, canonicalReactionUri, getReaction, getReactionsFor,
-  setReaction, getTimelineReaction, upsertBoostedNote, boostedCount,
-};
-// Stap 7 (shaer-drc): de volgwinkel woont in ap-following.js. fwStmts komt
-// mee terug voor de Accept-tak van de inbox en de verhuizing (FEP-7628);
-// wireFollowing krijgt onderaan zijn zes werktuigen.
-import {
-  fwStmts, wireFollowing,
-  webfingerResolve, listFollowing, setAutoBoost,
-  followActor, resolveRemoteActor, unfollowActor,
-} from './ap-following.js';
-export {
-  webfingerResolve, listFollowing, setAutoBoost,
-  followActor, resolveRemoteActor, unfollowActor,
-};
-// Stap 8 (shaer-drc): de peilingen wonen in ap-polls.js. parsePoll,
-// applyPollToNote en recordPollBallot komen terug voor de inbox, buildNote en
-// de backfill, maar blijven naar buiten toe prive zoals ze waren.
-import {
-  wirePolls,
-  parsePoll, applyPollToNote, recordPollBallot,
-  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate,
-  voteOnPoll, voteOnRemotePoll,
-} from './ap-polls.js';
-export {
-  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate,
-  voteOnPoll, voteOnRemotePoll,
-};
-// Stap 9 (shaer-drc): de inbox woont in ap-inbox.js. De schakelkast krijgt
-// onderaan zijn vierendertig werktuigen via wireInbox.
-import { handleInbox, wireInbox } from './ap-inbox.js';
-export { handleInbox };
-// Stap 10 (shaer-drc): de Cirkel woont in ap-cirkel.js. Geen wire: hij leest
-// alleen db.
-import { autoBoostCount, getCirkelPosts, getCirkelMembers } from './ap-cirkel.js';
-export { autoBoostCount, getCirkelPosts, getCirkelMembers };
-// Doorgeven wat hier altijd vandaan kwam, zodat elke bestaande aanroep blijft werken.
-export { AP_CONTEXT, actorId, noteId, guessMediaType };
-// De muziekkant woont in music/ (shaer-drc). Doorgeven wat hier altijd
-// vandaan kwam, zodat elke bestaande aanroep blijft werken.
-import { luisteraars } from './music/index.js';
-import { TRACK_KOLOMMEN,
-  playlistOpenTracks, siteOpenTracks, openTrack, trackHostPosts,
-  buildTrackAudio, buildTrackCollection, buildTrackCreate, trackUri, buildMixtapeObject, postMusicType,
-  buildPlaylistCollection, listPlaylistsAP, playlistLinkTags,
-  buildPostTrackCollection, uitgavePost,
-  buildLibrary, libraryId,
-  licentieUri, channelCategory,
-} from './music/index.js';
-export {
-  playlistOpenTracks, siteOpenTracks, openTrack, trackHostPosts,
-  buildTrackAudio, buildTrackCollection, buildTrackCreate,
-  buildPlaylistCollection, listPlaylistsAP, playlistLinkTags, licentieUri,
-  buildPostTrackCollection, uitgavePost,
-  buildLibrary, libraryId,
-};
-
-
-// Short random suffix so two activity ids minted in the same millisecond (e.g.
-// parallel saves) don't collide and get deduped by a receiver.
-const rid = () => crypto.randomBytes(4).toString('hex');
-
-// Keep only http(s) URLs — drops javascript:/data:/etc so a remote actor can't
-// smuggle a dangerous scheme into a stored href/src (rendered in owner-only views).
-
-const MAX_OUTBOX = 20;
-// Cache-buster for the music listen-link → forces Mastodon to re-crawl a FRESH
-// (square) player card. Bump this whenever the twitter:player card dimensions change.
-const FEDI_CARD_VER = '2';
-
-// ── content negotiation ───────────────────────────────────────────
-// True when the caller wants ActivityPub JSON rather than the HTML page.
-export function apWants(req) {
-  const a = String(req.headers.accept || '').toLowerCase();
-  return a.includes('application/activity+json') ||
-         (a.includes('application/ld+json') && a.includes('activitystreams'));
-}
-
-const AP_CONTENT_TYPE = 'application/activity+json; charset=utf-8';
-/**
- * Hetzelfde antwoord als de vorige keer? Dan 304 (Barts punt, 9-8).
- *
- * De inbox doet dit al met `since` + `wait`, en de guardian-wachtrijen niet: die
- * stuurden bij elke verversing de hele lijst terug, ook als er niets veranderd
- * was. Bij honderd wards is dat 217 KB JSON die de telefoon opnieuw moet
- * parsen -- over de lijn valt het mee (2,8 KB gzip), maar het OPBOUWEN van
- * veertienhonderd objecten is wat je merkt.
- *
- * EEN INHOUDS-ETAG, geen cursor. Een cursor vraagt een tweede beschrijving van
- * wanneer iets "veranderd" is, en die kan uit de pas gaan lopen met wat er
- * werkelijk in het antwoord staat; een hash van het antwoord zelf kan dat per
- * definitie niet. De server bouwt het antwoord nog steeds (26 ms) -- wat we
- * besparen is de overdracht en het parsen.
- *
- * NOOIT 304 OP EEN LEEG ANTWOORD. Dezelfde les als de '0'-uitzondering bij de
- * inbox: gaat er bij het opbouwen iets mis en komt er een lege lijst uit, dan is
- * die hash ook stabiel, en zou een client voor eeuwig 304 krijgen op niets.
- */
-export function etagFor(body) {
-  return `"${crypto.createHash('sha256').update(body).digest('base64url').slice(0, 27)}"`;
-}
-
-export function sendMaybe304(req, res, obj, { cacheControl, contentType } = {}) {
-  const body = JSON.stringify(obj);
-  const leeg = !obj || (Array.isArray(obj.orderedItems) && obj.orderedItems.length === 0);
-  res.set('Vary', 'Authorization');
-  if (!leeg) {
-    const tag = etagFor(body);
-    res.set('ETag', tag);
-    if (req.headers['if-none-match'] === tag) return res.status(304).end();
-  }
-  res.type(contentType || AP_CONTENT_TYPE);
-  // `no-cache` betekent NIET "niet bewaren": de client bewaart het antwoord en
-  // vraagt elke keer of het nog klopt. Precies wat we willen -- zonder dit
-  // stuurt een browser geen If-None-Match en is de ETag decoratie.
-  res.set('Cache-Control', cacheControl || 'private, no-cache');
-  return res.send(body);
-}
-
-export function sendAP(res, obj, cacheControl) {
-  res.type(AP_CONTENT_TYPE);
-  // A per-caller (e.g. guardian-widened) view must not be publicly cached.
-  res.set('Cache-Control', cacheControl || 'public, max-age=120');
-  res.send(JSON.stringify(obj));
-}
-
-// ── document builders ─────────────────────────────────────────────
-
-
-/** Eén Link uit een AS2 `url` kiezen op mediaType. Een `url` mag een string,
- *  een Link of een array van beide zijn; dit is de enige plek die dat weet. */
-function pickLink(url, test) {
-  const links = Array.isArray(url) ? url : (url ? [url] : []);
-  for (const l of links) {
-    const href = safeUrl(typeof l === 'string' ? l : (l && l.href));
-    const mt = (l && typeof l === 'object' && l.mediaType) || '';
-    if (href && test(mt)) return { href, mediaType: mt };
-  }
-  return null;
-}
-
-/**
- * De `url` van de actor als kanaal (shaer-0nh): de webpagina en, als die er is,
- * de RSS-feed ernaast.
- *
- * De RSS-link gaat er ALLEEN in voor de site waar de instance op gepind staat.
- * Sinds hub-modus verdween serveert routes/feed.js `/feed.xml` van de primaire
- * site en bestaat `/user/<slug>` niet meer als route; een feed-link voor een
- * andere site zou naar de verkeerde feed wijzen. Liever een link minder dan een
- * link die iemand anders' muziek belooft.
- */
-export function channelUrls(base, site) {
-  const isPrimair = site.slug === site.primary_slug;
-  const pagina = `${base}/${isPrimair ? '' : 'user/' + encodeURIComponent(site.slug)}`;
-  const uit = [{ type: 'Link', href: pagina, mediaType: 'text/html' }];
-  if (isPrimair) uit.push({ type: 'Link', href: `${base}/feed.xml`, mediaType: 'application/rss+xml' });
-  return uit;
-}
-
-
-/**
- * Wat de tijdlijn van een binnengekomen object nodig heeft, PER SOORT: de
- * inhoud-HTML, de bijlagen voor media_json, en de link van het item.
- *
- * Eén plek, zodat een nieuwe soort erbij een tak is en geen speurtocht. De
- * Krant rendert media_json al naar soort -- audio/* wordt een speler -- dus een
- * track komt vanzelf als echte speler binnen zonder dat de weergave iets van
- * Funkwhale hoeft te weten.
- */
-/**
- * De waarschuwingstekst van een object, of niets.
- *
- * `summary` IS in AS2 een SAMENVATTING -- "a natural language summarization of
- * the object". Dat Mastodon dat veld hergebruikt als waarschuwing is Mastodons
- * conventie, en die zet er `sensitive` bij. Zonder `sensitive` is een summary
- * dus gewoon een samenvatting.
- *
- * WordPress + ActivityPub stuurt daar de EXCERPT van een artikel in, netjes
- * afgekapt voor Mastodon. Wij lazen dat als waarschuwing en verborgen de post
- * daarmee achter zijn eigen eerste alinea (Barts melding, 13-8:
- * europeanpirates.eu). Niemand krijgt dan te zien wat er staat, en de
- * waarschuwing waarschuwt nergens voor.
- */
-export function contentWarning(o) {
-  if (!o || !o.sensitive) return null;
-  const s = typeof o.summary === 'string' ? o.summary.trim() : '';
-  return s || null;
-}
-
-export function timelineFields(o) {
-  // De hoes: een `image` op het object. Bij een Note alleen als terugval (daar
-  // is het de kaart-afbeelding van een player-post), bij een Audio altijd,
-  // want daar IS het de albumhoes.
-  const hoes = () => {
-    if (!o.image) return null;
-    const im = Array.isArray(o.image) ? o.image[0] : o.image;
-    const iu = safeUrl(typeof im === 'string' ? im : (im && im.url));
-    return iu ? { url: iu, type: (im && im.mediaType) || 'image/jpeg' } : null;
-  };
-
-  if (o.type === 'Audio') {
-    const geluid = pickLink(o.url, (mt) => /^audio\//i.test(mt));
-    // De webpagina van de track. Zonder mediaType is dat de veilige aanname:
-    // er een speler op zetten zou een HTML-pagina als geluid aanbieden.
-    const pagina = pickLink(o.url, (mt) => /^text\/html/i.test(mt)) || pickLink(o.url, (mt) => !mt);
-    const atts = [];
-    const h = hoes(); if (h) atts.push(h);              // eerst kijken, dan luisteren
-    if (geluid) atts.push({ url: geluid.href, type: geluid.mediaType || 'audio/mpeg' });
-    // Een Audio heeft geen `content`; de titel is wat er te lezen valt. Door de
-    // sanitizer, want hij komt van een vreemde server.
-    return {
-      html: o.name ? HtmlSanitizerService.sanitize(`<p>${o.name}</p>`) : '',
-      atts,
-      url: pagina ? pagina.href : null,
-    };
-  }
-
-  // Een ARTIKEL heeft een titel, en die is het eerste wat je wilt zien. Zonder
-  // dit kwam een WordPress-post binnen als kale body: de titel zit in `name` en
-  // die gooiden we weg, terwijl de excerpt in `summary` ten onrechte als
-  // waarschuwing dienstdeed. Nu allebei goed -- en dit is dezelfde greep die
-  // resolveRemoteNote al doet voor niet-Note-objecten, dus de tijdlijn en het
-  // antwoordpad zeggen eindelijk hetzelfde.
-  if (o.type && o.type !== 'Note' && typeof o.name === 'string' && o.name.trim()) {
-    const kop = `<p><strong>${HtmlSanitizerService.escape ? HtmlSanitizerService.escape(o.name) : o.name}</strong></p>`;
-    const atts = mediaFromNote(o);
-    const pagina = pickLink(o.url, (mt) => !mt || /html/i.test(mt));
-    return {
-      html: HtmlSanitizerService.sanitize(kop + (o.content || '')),
-      atts,
-      url: pagina ? pagina.href : null,
-    };
-  }
-
-  // Note / Question -- ongewijzigd gedrag.
-  const atts = (Array.isArray(o.attachment) ? o.attachment : [])
-    .map((a) => ({ url: safeUrl(a && a.url), type: (a && a.mediaType) || '' }))
-    .filter((m) => m.url);
-  if (!atts.some((m) => !m.type || /image/i.test(m.type))) {
-    const h = hoes(); if (h) atts.push(h);
-  }
-  const pagina = pickLink(o.url, () => true);
-  return { html: HtmlSanitizerService.sanitize(o.content || ''), atts, url: pagina ? pagina.href : null };
-}
-
-/**
- * De site achter een library-uri, of null. Zelfde strengheid als localSlugOf:
- * de uri moet met ONZE basis beginnen en de site moet bestaan -- anders levert
- * andermans /library met dezelfde padstaart hier een volger op onze naam op.
- */
-function libraryOwnerSlug(uri) {
-  const u = String(uri || '');
-  if (!u.endsWith('/library')) return null;
-  return localSlugOf(u.slice(0, -'/library'.length));
-}
-
-export function buildActor(base, site) {
-  const id = actorId(base, site.slug);
-  const keys = getOrCreateKeys(site.slug);
-  // FEP-633c §5.3: a ward's follows are gated (guardians approve), so the actor
-  // MUST advertise manuallyApprovesFollowers:true — otherwise a follower's server
-  // (Mastodon) assumes auto-accept and shows "Following" while we hold it pending.
-  const isWard = (() => { try { return Guardianship.listGuardians(site.slug).length > 0; } catch { return false; } })();
-  const actor = {
-    '@context': AP_CONTEXT,
-    id,
-    type: 'Person',
-    preferredUsername: site.slug,
-    name: site.title || site.slug,
-    summary: site.tagline || site.description || '',
-    // Een Link-ARRAY in plaats van een kale string (shaer-0nh): zo adverteert
-    // een kanaal zichzelf, en zo vindt een podcast-app de feed. De text/html
-    // staat VOORAAN, want een lezer die maar één url verwacht pakt de eerste --
-    // dezelfde vorm die Funkwhale in productie met Mastodon uitwisselt.
-    url: channelUrls(base, site),
-    ...(channelCategory(site) ? { category: channelCategory(site) } : {}),
-    // …and the same honesty for the OWNER gate (Robins wens, 18-8): a site
-    // with approve_followers on holds follows pending until the owner decides.
-    manuallyApprovesFollowers: isWard || !!site.approve_followers,
-    discoverable: true,
-    inbox: `${id}/inbox`,
-    outbox: `${id}/outbox`,
-    followers: `${id}/followers`,
-    following: `${id}/following`,
-    featured: `${id}/featured`,
-    // AS2-kern `streams`: "supplementary Collections which may be of
-    // interest" -- precies wat de playlist-lijst is (shaer-ayc, stap 2).
-    // Geen eigen vocabulaire nodig, en wie het niet kent negeert het.
-    streams: [`${id}/tracks`, `${id}/playlists`],
-    // AP §5.6: the private blocked collection (owner-only GET). The server
-    // list is the source of truth for Shaer's "in Orbit"; clients keep no
-    // separate state.
-    blocked: `${id}/blocked`,
-    // FEP-1580: de vertaaltabel van een verhuizing plus de Moves die hem
-    // rechtvaardigen. Deze twee staan er ALTIJD, ook leeg, en dat is met opzet:
-    // de FEP wijst er apart op dat "een verhuizing zonder objecten" en "een
-    // server die dit niet kent" anders niet uit elkaar te houden zijn.
-    migration: `${id}/migration`,
-    moves: `${id}/moves`,
-    // FEP-633c §2: shaer:guardians / shaer:isGuardian / shaer:queues
-    // (guardianship module owns these).
-    ...Guardianship.guardianshipActorProps(id, site.slug),
-    // C2S clients (Shaer apps) discover auth + upload here — no hardcoded paths.
-    // All four are ActivityPub-spec `endpoints` terms. Dynamic client registration
-    // (RFC 7591) is discovered via /.well-known/oauth-authorization-server, not here.
-    endpoints: {
-      sharedInbox: `${base}/ap/inbox`,
-      oauthAuthorizationEndpoint: `${base}/oauth/authorize`,
-      oauthTokenEndpoint: `${base}/oauth/token`,
-      uploadMedia: `${id}/uploadMedia`,
-    },
-    publicKey: {
-      id: `${id}#main-key`,
-      owner: id,
-      publicKeyPem: keys.public_pem,
-    },
-  };
-  if (site.profile_photo) {
-    const u = /^https?:/.test(site.profile_photo) ? site.profile_photo : `${base}${site.profile_photo.startsWith('/') ? '' : '/'}${site.profile_photo}`;
-    actor.icon = { type: 'Image', url: u };
-  }
-  // Account creation date — shown by Mastodon + read by indexers (additive, standard AS2).
-  if (site.created_at) { try { actor.published = new Date(site.created_at).toISOString(); } catch { /* skip bad date */ } }
-  // FEP-7628: former identities this account claims. The OLD server checks for
-  // exactly this back-reference before it will move followers here, so the
-  // list must be on the public actor, not tucked away in settings.
-  try {
-    const aka = JSON.parse(site.ap_aliases || '[]');
-    if (Array.isArray(aka)) {
-      const clean = aka.filter((u) => typeof u === 'string' && /^https?:\/\//i.test(u) && u !== id);
-      if (clean.length) actor.alsoKnownAs = clean;
-    }
-  } catch { /* skip malformed ap_aliases */ }
-  // FEP-7628 slice 3: this account moved. The old actor stays online AS A
-  // SIGNPOST — that is the whole point of keeping it: whoever missed the Move
-  // activity (offline server, later visitor) still learns where we went by
-  // fetching us. Per the FEP the moved actor "should be considered inactive",
-  // and publishers should stop delivering here.
-  if (site.moved_to && /^https?:\/\//i.test(String(site.moved_to))) actor.movedTo = String(site.moved_to);
-  // Zie movedLock() verderop: het serveren van movedTo is de ENE helft, het
-  // stilzetten van de uitgaande kant de andere.
-  // De MusicBrainz-koppeling van de artiest (shaer-mbz). Alleen als hij ZELF
-  // gekozen heeft -- er staat niets als er niets gekoppeld is, want een lege
-  // of geraden verwijzing is erger dan geen.
-  //
-  // schema:sameAs en niet alsoKnownAs: dat laatste is in AS2 voor vroegere
-  // identiteiten van dezelfde actor, en FEP-7628 leunt erop bij een verhuizing.
-  // Een MBID hier neerzetten zou een verhuizing kunnen laten mislukken.
-  const mbUrl = artiestUrl(site.mb_artist_id);
-  if (mbUrl) actor.sameAs = mbUrl;
-  // Profile links → PropertyValue rows: Mastodon/PeerTube/WordPress-ActivityPub render these as
-  // profile metadata (rel=me enables link-back verification). Additive; ignored by simpler receivers.
-  try {
-    const links = JSON.parse(site.profile_links || '[]');
-    if (Array.isArray(links) && links.length) {
-      const esc = (s) => String(s).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
-      const rows = links
-        .filter((l) => l && l.url && /^https?:/i.test(l.url))
-        .map((l) => ({
-          type: 'PropertyValue',
-          name: esc(l.platform || 'Link'),
-          value: `<a href="${esc(l.url).replace(/"/g, '&quot;')}" rel="me nofollow noopener" target="_blank">${esc(String(l.url).replace(/^https?:\/\//, ''))}</a>`,
-        }));
-      if (rows.length) actor.attachment = rows;
-    }
-  } catch { /* skip malformed profile_links */ }
-  return actor;
-}
-
-// Does a post's audio shortcodes reference at least one PLAYABLE (file-backed)
-// track? Link-only tracks (external Spotify/YouTube, media_id NULL) don't count —
-// they have no Klonkt-hosted audio to embed, so no player card / cover-suppression.
-export function hasPlayableAudio(content, siteId) {
-  if (!content || !/\[\[(track|album|playlist):/i.test(content)) return false;
-  try {
-    for (const m of content.matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) { const r = db.prepare('SELECT media_id FROM audio_tracks WHERE id = ?').get(m[1]); if (r && r.media_id) return true; }
-    for (const m of content.matchAll(/\[\[album:([^\]]+)\]\]/g)) { if (db.prepare('SELECT 1 FROM audio_tracks WHERE site_id = ? AND album = ? AND media_id IS NOT NULL LIMIT 1').get(siteId, m[1].trim())) return true; }
-    for (const m of content.matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) { if (db.prepare('SELECT 1 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 LIMIT 1').get(m[1])) return true; }
-  } catch { /* non-fatal */ }
-  return false;
-}
-
-// fedi_open tracks → real AS2 Audio attachments (the actual file URL, served ungated) so
-// EVERY client incl. the Mastodon apps plays them inline natively. Gated tracks (default)
-// stay link/card-only — the file is never exposed for them. Resolve from post.content so a
-// later body mutation can't affect it.
-//
-// Staat apart en niet meer midden in buildNote, omdat een BETAALDE post hem ook
-// nodig heeft: daar staat de muur om de TEKST en niet om de muziek.
-function openAudioAttachments(base, site, post) {
-  const openAudio = [];
-  if (!/\[\[(track|album|playlist):/i.test(post.content || '')) return openAudio;
-  const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
-  const seenA = new Set();
-  const addRow = (r) => {
-    const fn = r.filename || (r.storage_path || '').split('/').pop();
-    if (!fn || seenA.has(fn)) return; seenA.add(fn);
-    const a = { type: 'Audio', mediaType: r.mime_type || 'audio/mpeg', url: `${base}/audio/stream/${encodeURIComponent(fn)}`, name: r.title || 'Audio' };
-    // Cover art on the Audio attachment (AS2 `icon`): track cover, else the post cover.
-    // Mastodon renders it as the artwork thumbnail on its native audio player.
-    const art = abs(r.cover_url || post.cover_image_url || null);
-    if (art) a.icon = { type: 'Image', mediaType: guessMediaType(art), url: art };
-    openAudio.push(a);
-  };
-  const SEL = 'SELECT t.title, t.cover_url, m.filename, m.storage_path, m.mime_type FROM audio_tracks t JOIN media m ON m.id = t.media_id WHERE t.fedi_open = 1 AND ';
-  try {
-    for (const mm of (post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) { const r = db.prepare(SEL + 't.id = ?').get(mm[1]); if (r) addRow(r); }
-    for (const mm of (post.content || '').matchAll(/\[\[album:([^\]]+)\]\]/g)) for (const r of db.prepare(SEL + 't.site_id = ? AND t.album = ? ORDER BY t.rowid').all(site.id, mm[1].trim())) addRow(r);
-    for (const mm of (post.content || '').matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) for (const r of db.prepare('SELECT t.title, t.cover_url, m.filename, m.storage_path, m.mime_type FROM playlist_tracks pt JOIN audio_tracks t ON t.id = pt.track_id JOIN media m ON m.id = t.media_id WHERE t.fedi_open = 1 AND pt.playlist_id = ? ORDER BY pt.position').all(mm[1])) addRow(r);
-  } catch { /* non-fatal */ }
-  return openAudio;
-}
-
-// HET BANDJE OP DE DRAAD. Zonder dit stuk bestaat `Mixtape` alleen in onze
-// eigen code: de playlist-collectie blijft namelijk een OrderedCollection
-// (dat moet, anders verliest een lezer die `type` als tekst uitpakt het hele
-// object), en dan zegt niets naar buiten toe ooit dat dit een cassette is.
-// Gemeten op 21-8: in de Note van een mixtape-post kwam het woord Mixtape
-// niet voor, en de hub gooide zo'n bandje daarom stil weg.
-//
-// Als bijlage en niet als het object zelf: de post blijft een Note, zodat
-// Mastodon en alles wat `Mixtape` niet kent gewoon een bericht met audio
-// ziet. Wie het type wel kent, vindt het bandje als geheel.
-//
-// Het bandje draagt alleen wat al open staat: playlistOpenTracks filtert op
-// fedi_open. Daarom is het veilig om hem ook aan een betaalde teaser te hangen.
-function mixtapeAttachment(base, site, post) {
-  try {
-    const soort = postMusicType(post.content || '', site.id);
-    if (!soort || soort.type !== 'mixtape' || !soort.collectie || !soort.collectie.id) return null;
-    const pl = db.prepare('SELECT * FROM playlists WHERE id = ? AND site_id = ?')
-      .get(soort.collectie.id, site.id);
-    if (!pl) return null;
-    return buildMixtapeObject(base, site, { ...pl, _post: post }, playlistOpenTracks(pl.id)) || null;
-  } catch { return null; /* een bandje minder is geen kapotte post */ }
-}
-
-// A single post as an AS2 Note (the object), and as a Create activity (for outbox/delivery).
-export function buildNote(base, site, post, opts = {}) {
-  // Replies are Notes too. buildNote is the single entry point for ALL Notes; a reply is
-  // (for now) the simple flavor: pre-baked content, no title/cover/image/audio/embed
-  // machinery, addressed to the parent actor + thread. This early branch keeps that output
-  // byte-identical to the old buildReplyNote. When rich replies land (images/audio/embeds),
-  // this branch collapses and replies flow through the full post pipeline below. `post` here
-  // is the ap_outbox reply row (id, in_reply_to, content, post_slug, created_at, to_actor).
-  if (opts.isReply) {
-    const meR = actorId(base, site.slug);
-    // Rich replies: attachments column (JSON [{url, mediaType, name}]) → AS2
-    // attachment array with absolute URLs and the matching object type.
-    let replyAtt;
-    try {
-      const list = post.attachments ? JSON.parse(post.attachments) : [];
-      if (Array.isArray(list) && list.length) {
-        replyAtt = list.map((a) => ({
-          type: a.mediaType.startsWith('image/') ? 'Image' : a.mediaType.startsWith('audio/') ? 'Audio' : 'Video',
-          mediaType: a.mediaType,
-          url: /^https?:/i.test(a.url) ? a.url : `${base}${a.url}`,
-          name: a.name || undefined,
-        }));
-      }
-    } catch { /* malformed attachments never block the Note */ }
-    return {
-      id: noteId(base, post.id),
-      type: 'Note',
-      attributedTo: meR,
-      inReplyTo: post.in_reply_to || undefined,
-      content: post.content,
-      // Reply language (rich replies): the AS2 language map next to `content`.
-      contentMap: post.language ? { [post.language]: post.content } : undefined,
-      attachment: replyAtt,
-      url: post.post_slug ? `${base}/${encodeURIComponent(post.post_slug)}` : undefined,
-      published: toISO(post.created_at),
-      // A direct note (private mention, shaer-tqc) addresses ONLY its
-      // recipients: no Public anywhere, so it cannot be boosted and never
-      // shows in public timelines (the Mastodon DM model).
-      to: post.visibility === 'direct'
-        ? (JSON.parse(post.to_actors || '[]'))
-        : (post.to_actor ? [post.to_actor] : [PUBLIC]),
-      // Followers-only reply ('friends', shaer detail-view Reply): the parent
-      // author (in `to`) + our followers, but NO Public — it does not federate
-      // into open discovery. Default reply stays quiet-public (Public in cc).
-      cc: post.visibility === 'direct' ? []
-        : post.visibility === 'friends' ? [`${meR}/followers`]
-          : [PUBLIC, `${meR}/followers`],
-      // FEP-633c 5.2.1: a ward's call for help. Only ever on direct notes.
-      ...Guardianship.helpRequestProps(post),
-      ...Guardianship.waveProps(post),
-      ...Guardianship.awayProps(post),
-      // FEP-633c §2.2: object hint that the author is a ward.
-      ...Guardianship.hasGuardiansProps(site.slug),
-      tag: [
-        ...mentionTags(post.content),
-        ...hashtagTags(base, post.content),
-      ],
-    };
-  }
-  const id = noteId(base, post.id);
-  const aId = actorId(base, site.slug);
-  const human = `${base}/${encodeURIComponent(post.slug)}`;
-  // Mastodon ignores a Note's `name`, so put the title INTO the content (bold
-  // first line) — the standard blog→fediverse convention. post.content is
-  // already sanitized HTML; the title is plain text, so escape it.
-  const escTitle = String(post.title || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
-  const titleHtml = post.title ? `<p><strong>${escTitle}</strong></p>` : '';
-
-  // Paid post (klonkt-demo-aki): federate a PUBLIC teaser + link, never the full
-  // content, so nothing leaks past the paywall. Images stay home too.
-  //
-  // MAAR DE OPENGEZETTE AUDIO REIST WEL MEE (Robin, 24-8, naar aanleiding van
-  // boiert.eu/introducing-this-machine). De muur staat om de TEKST. `fedi_open`
-  // is een aparte, eenrichtings, per nummer bewust gezette vlag van de eigenaar,
-  // en die nummers federeren toch al los als eigen Audio-objecten met hun
-  // `context` naar deze post. Hield deze tak het bandje tegen, dan hield hij
-  // niets geheim -- alleen de VOLGORDE en het feit dat het een cassette is. In
-  // de hub viel het bandje daardoor uiteen in vier losse nummers onder een kale
-  // teaserkaart. Een cassette die terugwijst naar "lees verder (supporters)"
-  // dient de betaalde post beter dan vier weesnummers.
-  if (post.paid) {
-    const esc = (x) => String(x || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
-    const _firstP = (String(post.content || '').match(/<p[^>]*>([\s\S]*?)<\/p>/i) || [null, ''])[1] || '';
-    const rawTeaser = String(post.excerpt || '').trim()
-      || _firstP.replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim().slice(0, 280);
-    const openBijlagen = openAudioAttachments(base, site, post);
-    const band = mixtapeAttachment(base, site, post);
-    // Het bandje alleen als er ook echt iets open in zit: een cassette waarvan
-    // elk nummer gesloten is, is een lege doos met een titel erop.
-    if (band && openBijlagen.length) openBijlagen.push(band);
-    return {
-      '@context': AP_CONTEXT,
-      id,
-      type: 'Note',
-      attributedTo: aId,
-      content: `${titleHtml}<p>${esc(rawTeaser)}${rawTeaser ? '…' : ''}</p><p><a href="${human}">Lees de volledige post (supporters)</a></p>`,
-      url: human,
-      published: toISO(post.published_at || post.created_at || Date.now()),
-      ...(openBijlagen.length ? { attachment: openBijlagen } : {}),
-      to: [PUBLIC],
-      cc: [`${aId}/followers`],
-      tag: [...hashtagTags(base, post.content)],
-      replies: `${id}/replies`,
-      // DE WAARSCHUWING REIST MEE (Barts melding, 15-8). Deze vroege return liet
-      // `sensitive` en `summary` vallen, want die worden pas na de gewone tak
-      // gezet. Gevolg: een betaalde post met een waarschuwing ging ZONDER die
-      // waarschuwing de deur uit -- en de teaser is publiek, dus juist die had
-      // hem nodig. Een gevoelige teaser zonder vlag is erger dan geen teaser.
-      sensitive: !!post.nsfw,
-      ...(post.nsfw ? { summary: post.content_warning || 'Gevoelige inhoud' } : {}),
-      ...Guardianship.hasGuardiansProps(site.slug),
-    };
-  }
-
-  // Images travel as AP `attachment` (Mastodon strips <img> from content). Collect
-  // the cover + any inline <img>, make absolute, then strip <img> from the content
-  // to avoid duplicate rendering on clients that DO keep them.
-  const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
-  const hadAudio = /\[\[(track|album|playlist):/i.test(post.content || '');
-  const playable = hasPlayableAudio(post.content || '', site && site.id);
-  // A post with an external embed (Spotify/YouTube/SoundCloud/Vimeo/Bandcamp/Apple) should let
-  // Mastodon render the embed's player CARD. Mastodon shows EITHER media attachments OR a link
-  // card, never both — so when the post has an embed link we skip the image attachments so the
-  // card wins. (On Klonkt nothing changes: the cover + the embed player still render.)
-  const hasEmbed = (() => {
-    const c = post.content || '';
-    if (/\[\[embed:/i.test(c)) return true;
-    for (const m of c.matchAll(/https?:\/\/[^\s"'<>]+/gi)) if (AudioEmbedService.detectProvider(m[0])) return true;
-    return false;
-  })();
-  // Link-only tracks (external Spotify/YouTube/SoundCloud, no hosted file): collect their links
-  // so we federate them — Mastodon cards the first (its player), the rest show as clickable links
-  // — instead of a bare "listen on site" link, and we suppress the cover so the card can show.
-  const trackEmbedLinks = (() => {
-    if (playable) return [];
-    const out = [];
-    try {
-      for (const m of (post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
-        const r = db.prepare('SELECT media_id, link_spotify, link_youtube, link_soundcloud FROM audio_tracks WHERE id = ?').get(m[1]);
-        if (r && !r.media_id) for (const u of [r.link_spotify, r.link_youtube, r.link_soundcloud]) if (u && /^https?:\/\//i.test(u)) out.push(u);
-      }
-    } catch { /* non-fatal */ }
-    return [...new Set(out)].slice(0, 6);
-  })();
-  const noImages = playable || hasEmbed || trackEmbedLinks.length > 0; // suppress images → let the player/embed card show
-  const urls = [];
-  // Posts with PLAYABLE hosted audio suppress image attachments so Mastodon renders
-  // the player CARD (twitter:player) instead of the cover — media attachment and
-  // link/player card are mutually exclusive on Mastodon. Link-only audio (external)
-  // keeps its cover (no player card to show).
-  // An animated cover federates as the muted loop MP4 (→ a Video attachment): animated WebP is
-  // unreliable on Mastodon and its iOS apps; the MP4 plays everywhere. Else the still cover image.
-  // Each entry carries the media URL + its alt text (federated as the AS2 attachment `name`, for a11y).
-  // Media a C2S composer attached (shaer-j3uh): federate with their REAL
-  // mediaType, because the extension map below knows no audio and would call
-  // an m4a an Image. Pushed BEFORE the covers: a C2S video doubles as the
-  // cover video, and the URL-dedupe keeps the FIRST entry, which must be the
-  // one that knows its type and poster. Images also live inline in the
-  // content, so the dedupe keeps those single too.
-  try {
-    for (const a of JSON.parse(post.c2s_attachments || '[]')) {
-      if (a && a.url) urls.push({ url: abs(a.url), name: a.name || '', mt: a.mediaType, poster: a.poster ? abs(a.poster) : null });
-    }
-  } catch { /* malformed never blocks the Note */ }
-  if (post.cover_video_url && !noImages) urls.push({ url: abs(post.cover_video_url), name: post.cover_alt || '' });
-  else if (post.cover_image_url && !noImages) urls.push({ url: abs(post.cover_image_url), name: post.cover_alt || '' });
-  let body = post.content || '';
-  // Only federate inline images we can actually serve: absolute http(s) URLs, or our own
-  // /media/ uploads. A relative path we don't host (e.g. a stale /images/... ref) would 404
-  // and show up as a black tile in Mastodon's attachment grid. Carry the <img alt="…"> through
-  // as the attachment description.
-  if (!noImages) for (const m of body.matchAll(/<img\b[^>]*>/gi)) {
-    const tag = m[0];
-    const src = (tag.match(/\bsrc="([^"]+)"/i) || [])[1];
-    if (!src || !(/^https?:\/\//i.test(src) || src.startsWith('/media/'))) continue;
-    const alt = (tag.match(/\balt="([^"]*)"/i) || [])[1] || '';
-    urls.push({ url: abs(src), name: alt });
-  }
-  body = body.replace(/<img\b[^>]*>/gi, '');
-  // Video and audio tags leave the federated content the same way (30-7):
-  // they ride as AS2 attachments (c2s_attachments), and the tag itself
-  // carries a RELATIVE /media src that is dead everywhere but our own web.
-  // Leaving it in showed every remote reader a broken player above the
-  // working one. The web keeps its tags: this strip is federation-only.
-  body = body.replace(/<video\b[^>]*>[\s\S]*?<\/video>/gi, '').replace(/<video\b[^>]*\/?>/gi, '');
-  body = body.replace(/<audio\b[^>]*>[\s\S]*?<\/audio>/gi, '').replace(/<audio\b[^>]*\/?>/gi, '');
-  // Audio shortcodes: do NOT federate the raw audio file — Klonkt deliberately
-  // gates audio (the /audio/stream URL has friction), and shipping it as an AP
-  // audio attachment would hand Mastodon a plain, downloadable mp3 URL. Instead,
-  // replace the shortcodes with a "🎵 listen on the site" link so the post invites
-  // a click-through to the protected player (discovery without leaking the file).
-  const esc = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
-  // Elke titel met zijn track-id erbij, zodat hij hieronder een EIGEN link
-  // krijgt naar #track-<id> op de postpagina (shaer-38y). Zonder id was dit een
-  // vetgedrukte opsomming waar je niets mee kon: vijf namen en een enkele
-  // "listen on"-link naar de post als geheel. Elke track heeft daar al een
-  // anker -- direct ingesloten, in een album of in een playlist -- dus dit
-  // wijst naar precies het nummer waar de naam bij hoort.
-  const audioLabels = [];
-  try {
-    const zien = new Set();
-    const voegToe = (id, titel) => {
-      const t = String(titel || '').trim();
-      if (!t) return;
-      const sleutel = id || ('naam:' + t);
-      if (zien.has(sleutel)) return;
-      zien.add(sleutel);
-      audioLabels.push({ id: id || null, titel: t });
-    };
-    // In de volgorde van de POST: een enkele scan over alle drie de vormen,
-    // zodat de opsomming leest zoals de post is neergezet.
-    for (const m of body.matchAll(/\[\[(track|album|playlist):([^\]]+)\]\]/gi)) {
-      const soort = m[1].toLowerCase(), waarde = m[2].trim();
-      if (soort === 'track') {
-        const r = db.prepare('SELECT id, title FROM audio_tracks WHERE id = ?').get(waarde);
-        if (r) voegToe(r.id, r.title);
-      } else if (soort === 'album') {
-        const rs = db.prepare('SELECT id, title FROM audio_tracks WHERE site_id = ? AND album = ? ORDER BY rowid').all(site.id, waarde);
-        if (rs.length) for (const r of rs) voegToe(r.id, r.title);
-        else voegToe(null, waarde);            // album zonder tracks: dan maar de naam
-      } else {
-        for (const r of db.prepare('SELECT t.id, t.title FROM playlist_tracks pt JOIN audio_tracks t ON t.id = pt.track_id WHERE pt.playlist_id = ? ORDER BY pt.position').all(waarde)) voegToe(r.id, r.title);
-      }
-    }
-  } catch { /* non-fatal */ }
-  const openAudio = openAudioAttachments(base, site, post);
-  // ONVERTAALD voor een verhuizing (FEP-1580). De doelinstantie IS een Klonkt:
-  // die rendert [[track:]], [[album:]] en [[playlist:]] zelf en maakt er een
-  // speler van. Bakken we ze eerst om, dan komt er een tekstlink aan en is de
-  // speler weg. Onherstelbaar bovendien: het bakken STRIPT de shorthand en
-  // plakt achteraan hooguit VIER titels, dus een album van tien nummers
-  // overleeft het niet.
-  //
-  // Dezelfde regel als bij de outbox en de tracks: wie ondertekend vraagt
-  // namens de actor waar wij naartoe verhuisd zijn, krijgt onze eigen kijk.
-  if (!opts.rauweInhoud) body = body.replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
-  // External embeds ([[embed:url]]) → emit the bare URL as a link so Mastodon
-  // renders its OWN preview/player card (YouTube/Spotify/SoundCloud/etc) instead
-  // of federating the raw shortcode text.
-  body = body.replace(/\[\[embed:([^\]]+)\]\]/gi, (mm, raw) => {
-    const u = esc(raw.trim().replace(/&amp;/g, '&'));
-    return `<p><a href="${u}">${u}</a></p>`;
-  });
-  if (hadAudio && !opts.rauweInhoud) {
-    // Elke titel als eigen link naar zijn anker; een titel zonder id (een
-    // albumnaam zonder tracks) blijft gewone tekst.
-    const lbl = audioLabels.slice(0, 4)
-      .map((a) => (a.id ? `<a href="${human}#track-${esc(a.id)}">${esc(a.titel)}</a>` : esc(a.titel)))
-      .join(', ');
-    if (trackEmbedLinks.length) {
-      // Link-only track(s): emit the external link(s). Mastodon cards the first (Spotify → its
-      // player), the rest render as clickable links — the fediverse-native "embed + links".
-      body += `<p>🎵 ${lbl ? `<strong>${lbl}</strong>` : ''}</p>`;
-      for (const u of trackEmbedLinks) { const eu = esc(u); body += `<p><a href="${eu}">${eu}</a></p>`; }
-    } else {
-      // For playable posts, append a version param to the listen-link so Mastodon
-      // sees a NEW card URL and re-crawls it (fresh SQUARE player card) instead of
-      // reusing the cached landscape one. Invisible: the link TEXT stays clean, the
-      // page ignores the param. Bump FEDI_CARD_VER when the card dimensions change.
-      const listenHref = playable ? `${human}?fc=${FEDI_CARD_VER}` : human;
-      body += `<p>🎵 ${lbl ? `<strong>${lbl}</strong> — ` : ''}<a href="${listenHref}">listen on ${esc(site.title || 'the site')}</a></p>`;
-    }
-  }
-  // Klonkt renders post content with white-space:pre-wrap, so raw newlines ARE line
-  // breaks on the site. Mastodon (plain HTML) collapses whitespace and would drop them,
-  // so convert newlines to <br> for the federated copy (content already made with
-  // shift+enter uses <br> and has no \n → this is a no-op there).
-  body = body.replace(/\r?\n/g, '<br>');
-  body = linkHashtags(base, body); // link inline #hashtags in the post body too
-  body = linkUrls(body);           // bare URLs → clickable links on the federated copy
-  // Append the tags-field hashtags to the content so Mastodon renders them as clickable
-  // hashtags (a Hashtag that's only in the `tag` array isn't shown inline). CamelCase
-  // multi-word tags; skip any already present inline in the body.
-  {
-    const inlineTags = new Set(hashtagTags(base, body).map((h) => h.name.slice(1).toLowerCase()));
-    const addSeen = new Set();
-    const tagLinks = normalizeTags(post.tags).map(tagParts).filter(Boolean)
-      .filter((p) => !inlineTags.has(p.slug) && !addSeen.has(p.slug) && addSeen.add(p.slug))
-      .map((p) => `<a href="${base}/tag/${encodeURIComponent(p.slug)}" class="mention hashtag" rel="tag">#${p.label}</a>`);
-    if (tagLinks.length) body += `<p>${tagLinks.join(' ')}</p>`;
-  }
-  const seen = new Set();
-  const attachment = urls.filter((x) => x && x.url)
-    .filter((x) => { if (seen.has(x.url)) return false; seen.add(x.url); return true; })
-    .map((x) => { const mt = x.mt || guessMediaType(x.url); // the stored type wins; the extension map is the fallback
-      const ty = /^image\//i.test(mt) ? 'Image' : /^video\//i.test(mt) ? 'Video' : /^audio\//i.test(mt) ? 'Audio' : 'Document';
-      const a = { type: ty, mediaType: mt, url: x.url };
-      if (x.name) a.name = String(x.name).slice(0, 1500); // alt text / description (AS2 `name`)
-      if (x.poster) a.icon = { type: 'Image', url: x.poster }; // the video's still (shaer-zowq)
-      return a; });
-  for (const a of openAudio) attachment.push(a); // fedi_open tracks → native Audio players
-
-  // Het bandje als bijlage — zie mixtapeAttachment() voor het waarom.
-  const tape = mixtapeAttachment(base, site, post);
-  if (tape) attachment.push(tape);
-
-  // Inline @user@host mentions: the Mention tag objects + the mentioned actor URIs. Only
-  // present when the content was already mention-linked (deliverCreate/Update resolve them
-  // at send time); a plain buildNote (outbox/notes) yields none.
-  const _mentionTags = mentionTags(body);
-  const _mentionCc = _mentionTags.map((t) => t.href);
-
-  const note = {
-    id,
-    type: 'Note',
-    attributedTo: aId,
-    content: titleHtml + body,
-    url: human,
-    published: new Date(post.published_at || post.created_at || Date.now()).toISOString(),
-    // fan_only = "fans only" → followers-only visibility (delivered to your followers
-    // but not addressed to Public, so Mastodon shows it only to them and can't boost it).
-    to: (post.fan_only || post.ap_visibility === 'quiet') ? [`${aId}/followers`] : [PUBLIC],
-    // Mentioned actors (from inline @user@host links the caller resolved) are addressed in cc
-    // so Mastodon notifies them; empty unless the content was mention-linked (delivery time).
-    cc: [...new Set([
-      ...(post.ap_visibility === 'quiet' ? [PUBLIC] : []),          // quiet public: Public in cc, not to
-      ...((post.fan_only || post.ap_visibility === 'quiet') ? [] : [`${aId}/followers`]),
-      ..._mentionCc])],
-    tag: [...buildHashtagList(base, post.tags, body), ..._mentionTags, ...playlistLinkTags(base, site, post.content, post)],
-    replies: `${id}/replies`,
-    // NSFW → Mastodon-style content warning: sensitive (blurs media) + a summary/spoiler
-    // (hides the whole post behind a "Gevoelige inhoud" button until the reader opens it).
-    sensitive: !!post.nsfw,
-  };
-  // FEP-633c §2.2: object hint that the author is a ward (safely ignorable).
-  Object.assign(note, Guardianship.hasGuardiansProps(site.slug));
-  // FEP-044f: this post quotes a fediverse object. Emit it the way the network
-  // actually reads it, and address the quoted author so they get told.
-  applyQuoteProps(note, post.quote_uri, post.quote_actor);
-  if (post.nsfw) note.summary = post.content_warning || 'Gevoelige inhoud';
-  if (attachment.length) note.attachment = attachment;
-  // When the cover attachment is suppressed (hosted audio OR an external embed/link-only track →
-  // so Mastodon shows the player/link card, not media), still expose the cover via AS2 `image` so
-  // card/grid consumers (the Klonkt Cirkel/News feed) can show it. Mastodon ignores a Note's
-  // `image`, so its card is unaffected — but a Klonkt receiver reads it (handleInbox o.image).
-  if (post.cover_image_url && noImages) {
-    const cov = abs(post.cover_image_url);
-    if (cov) { note.image = { type: 'Image', mediaType: guessMediaType(cov), url: cov }; if (post.cover_alt) note.image.name = String(post.cover_alt).slice(0, 1500); }
-  }
-  // Experiment (mirrors PeerTube / schema.org `embedUrl`): point at the GATED player page
-  // (/embed) so a client that honours embedUrl can show an inline player WITHOUT ever
-  // getting the audio file — the anti-steal posture is untouched. `embedUrl` is a real
-  // standard field name (not a Klonkt invention); if Mastodon's apps honour it on a Note we
-  // make it JSON-LD-clean with a context term, otherwise it degrades to the player card.
-  if (playable) note.embedUrl = `${base}/embed?post=${encodeURIComponent(post.slug)}`;
-  // Content language → AS2 contentMap (a BCP-47-keyed copy of the content). Mastodon reads the
-  // language from its key for the timeline language filter + the translate button. Emitted
-  // alongside `content` (Mastodon sends both); a plain receiver just uses `content`.
-  if (post.language && /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(post.language)) note.contentMap = { [post.language]: note.content };
-  // A hosted poll → federate as an AS2 Question (options + live tally). Do this last so it
-  // reuses the note's content/addressing/tags, then swaps the type and strips media.
-  const ownPoll = parseOwnPoll(post.poll_json);
-  if (ownPoll) applyPollToNote(note, post.id, ownPoll);
-  return note;
-}
-
-// All reply note URIs on a local post (inbound fediverse replies + our own
-// outbound replies) — backs the Note's `replies` Collection so remote servers
-// can fetch the whole thread.
-export function getReplyUris(base, postId) {
-  const out = [];
-  try {
-    for (const r of db.prepare("SELECT object_uri FROM ap_interactions WHERE kind = 'reply' AND post_id = ? AND object_uri != '' ORDER BY created_at").all(postId)) out.push(r.object_uri);
-    for (const r of db.prepare('SELECT id FROM ap_outbox WHERE post_id = ? ORDER BY rowid').all(postId)) out.push(`${base}/ap/notes/${r.id}`);
-  } catch { /* non-fatal */ }
-  return out;
-}
-
-// Notifications "seen" tracking → a real bell badge. Stored per site in app_settings.
-export function markNotificationsSeen(slug) {
-  try {
-    db.prepare("INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP")
-      .run(`fedi_notif_seen:${slug}`, new Date().toISOString());
-  } catch { /* non-fatal */ }
-}
-export function countUnseenNotifications(slug) {
-  try {
-    const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(`fedi_notif_seen:${slug}`);
-    const seen = row ? Date.parse(row.value) : 0;
-    let n = 0;
-    for (const it of getNotifications(slug, 50)) { if (Date.parse(it.created_at) > seen) n++; }
-    return n;
-  } catch { return 0; }
-}
-// The seen-watermark itself (ms epoch, 0 = never marked) — the Messages page reads it
-// BEFORE marking seen, so it can render unread dots on the items newer than last visit.
-export function notificationsSeenAt(slug) {
-  try {
-    const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(`fedi_notif_seen:${slug}`);
-    return row ? (Date.parse(row.value) || 0) : 0;
-  } catch { return 0; }
-}
-
-// Messages = the unified inbox (Reacties + Meldingen merged, decision Robin+Bart 2026-07-16):
-// every notification PLUS your own outbound replies ('sent', with edit/delete via their
-// outboxId), sorted as one stream. Consecutive likes/boosts on the same post collapse into
-// one grouped item (actors list + count) so activity doesn't drown out conversations.
-/** ap_outbox.attachments ([{url, mediaType, name}]) naar de vorm die note-body
- *  leest (media_json: [{url, type, name}]). Geeft null bij niets of rommel,
- *  zodat een kapotte kolom hooguit media kost en niet de hele regel. */
-function outboxMediaJson(attachments) {
-  if (!attachments) return null;
-  try {
-    const list = JSON.parse(attachments);
-    if (!Array.isArray(list) || !list.length) return null;
-    const media = list
-      .filter((a) => a && a.url)
-      .map((a) => ({ url: a.url, type: a.mediaType || a.type || '', name: a.name || undefined }));
-    return media.length ? JSON.stringify(media) : null;
-  } catch { return null; }
-}
-
-export function getMessages(slug, limit, offset) {
-  const off = Math.max(0, offset || 0);
-  const lim = limit || 60;
-  // The stream is grouped (consecutive likes/boosts collapse), so paging is done by
-  // recomputing the whole stream top-down and slicing [off, off+lim] — stable across
-  // pages. Fetch a buffer past off+lim so grouping-shrinkage can't hide a full page.
-  const need = off + lim + 100;
-  const items = getNotifications(slug, need);
-  try {
-    for (const m of listOutbox(slug).slice(0, need)) {
-      items.push({
-        type: 'sent', outboxId: m.id, to_handle: m.to_handle, to_actor: m.to_actor, to_actors: m.to_actors,
-        in_reply_to: m.in_reply_to, post_slug: m.post_slug, content: m.content,
-        editable: m.editable, language: m.language, created_at: m.created_at,
-        // Je eigen bericht hoort er hetzelfde uit te zien als dat van een ander:
-        // note-body rendert Berichten, de Krant en de Guardian-PWA, maar leest
-        // media uit media_json met een `type`, terwijl ap_outbox ze als
-        // `attachments` met een `mediaType` bewaart. Zonder deze vertaling kwam
-        // een foto die JIJ meestuurde als kale tekst binnen.
-        media_json: outboxMediaJson(m.attachments),
-      });
-    }
-  } catch { /* ignore */ }
-  // Een verzonden antwoord kent zijn post_slug maar niet de titel (ap_outbox
-  // bewaart die niet). Zonder titel toont een draad waarin JIJ als enige iets
-  // zei alleen een slug, dus vullen we ze in één query aan.
-  try {
-    const missing = [...new Set(items.filter((i) => i.post_slug && !i.post_title).map((i) => i.post_slug))];
-    if (missing.length) {
-      const rows = db.prepare(
-        `SELECT slug, title FROM posts WHERE slug IN (${missing.map(() => '?').join(',')})
-           AND site_id = (SELECT id FROM sites WHERE slug = ?)`,
-      ).all(...missing, slug);
-      const byslug = new Map(rows.map((r) => [r.slug, r.title]));
-      for (const i of items) if (i.post_slug && !i.post_title) i.post_title = byslug.get(i.post_slug) || null;
-    }
-  } catch { /* zonder titel valt de draad terug op de slug */ }
-  items.sort((a, b) => _msgTs(b) - _msgTs(a)); // NaN-safe (zie getNotifications)
-  const out = [];
-  for (const it of items) {
-    const prev = out[out.length - 1];
-    if ((it.type === 'like' || it.type === 'announce') && prev && prev.type === it.type
-        && prev.post_slug === it.post_slug) {
-      prev.actors = prev.actors || [prev.name || prev.handle || '?'];
-      prev.actors.push(it.name || it.handle || '?');
-      prev.count = (prev.count || 1) + 1;
-      continue;
-    }
-    out.push(it);
-  }
-  // Antwoorden, mentions en je eigen verzonden berichten vouwen samen tot
-  // draden; likes/boosts/follows/reports blijven losse regels. Na deze stap
-  // telt een draad als één item voor de paginering, wat klopt: je scrolt door
-  // gesprekken, niet door losse zinnen.
-  return groupConversations(out).slice(off, off + lim);
-}
-
-// De drie soorten die samen een gesprek vormen. Vroeger zaten ze in drie
-// aparte chips: 'reply' en 'mention' onder Berichten/Gesprekken (afhankelijk van
-// de zichtbaarheid) en 'sent' onder Verzonden. Wie een uitwisseling wilde volgen
-// moest dus tussen chips heen en weer, terwijl het één draad is.
-const CONV_TYPES = new Set(['reply', 'mention', 'sent']);
-
-/** Waar hangt dit bericht aan? Twee soorten draden, en de volgorde telt:
- *
- *  1. Aan een post van jou. Een ontvangen antwoord kent zijn post via de join
- *     op `posts`, een verzonden antwoord via ap_outbox.post_slug. Dat is
- *     dezelfde sleutel, en daarom staan ze nu in dezelfde draad.
- *  2. Aan een persoon. Een mention hangt aan niets van jou (het is iemands
- *     eigen post waarin je genoemd wordt) en heeft geen post_slug; die draad
- *     loopt per tegenpartij.
- *
- *  De post wint van de persoon: twee mensen die onder dezelfde post reageren
- *  voeren één gesprek, geen twee. Geeft null terug voor alles wat geen gesprek
- *  is (likes, boosts, follows, reports, poll-uitslagen); die stromen ongemoeid
- *  door.
- */
-export function threadKey(it) {
-  if (!it || !CONV_TYPES.has(it.type)) return null;
-  if (it.post_slug) return `post:${it.post_slug}`;
-  let who = it.handle || it.to_handle || '';
-  // Een direct bericht kan zonder to_handle in de tabel staan (de handle van de
-  // ontvanger was niet af te leiden). De eerste uit to_actors is dan alsnog de
-  // tegenpartij, en zonder deze terugval kreeg een gesprek dat JIJ begon geen
-  // draad -- precies het geval waarin het meest onlogisch is dat het los blijft.
-  if (!who && it.to_actors) {
-    try {
-      const first = JSON.parse(it.to_actors)[0];
-      if (first) who = deriveHandle(first);
-    } catch { /* geen bruikbare lijst → geen sleutel, het blijft een losse regel */ }
-  }
-  const norm = String(who || '').trim().toLowerCase().replace(/^@/, '');
-  return norm ? `actor:${norm}` : null;
-}
-
-/** Vouw losse berichten samen tot draden, met alles wat geen gesprek is
- *  ongemoeid ertussen. Verwacht [items] al gesorteerd op created_at aflopend
- *  (zoals getMessages ze aanlevert); de draad komt daardoor op de plek van zijn
- *  nieuwste bericht te staan en `created_at` van de draad IS dat bericht. Binnen
- *  de draad draait het om: een gesprek leest naar beneden, oud naar nieuw.
- */
-export function groupConversations(items) {
-  const threads = new Map();
-  const out = [];
-  for (const it of items || []) {
-    const key = threadKey(it);
-    if (!key) { out.push(it); continue; }
-    let t = threads.get(key);
-    if (!t) {
-      // Eerste keer dat we deze draad zien = het nieuwste bericht erin, want de
-      // invoer is aflopend gesorteerd. Vandaar created_at hier en niet later.
-      t = { type: 'thread', key, post: null, people: [], messages: [], created_at: it.created_at };
-      threads.set(key, t);
-      out.push(t);
-    }
-    t.messages.push(it);
-    // De context bij de draad: gaat het over een post, dan hoort de link
-    // erbij, anders is een los antwoord in een lijst niet te plaatsen.
-    // De titel blijft LEEG zolang hij onbekend is, in plaats van terug te
-    // vallen op de slug: het nieuwste bericht in een draad is vaak je eigen
-    // verzonden antwoord, en dat kent alleen de slug. Zou die de titel worden,
-    // dan kan het ontvangen antwoord eronder de echte titel niet meer
-    // invullen. De terugval op de slug hoort in de weergave, niet in de data.
-    if (it.post_slug) {
-      if (!t.post) t.post = { slug: it.post_slug, title: it.post_title || null };
-      else if (!t.post.title && it.post_title) t.post.title = it.post_title;
-    }
-  }
-  for (const t of threads.values()) {
-    t.messages.sort((a, b) => _msgTs(a) - _msgTs(b));
-    t.count = t.messages.length;
-    // Wie zit er in dit gesprek, jij niet meegerekend: 'sent' ben jij.
-    const seen = new Set();
-    for (const m of t.messages) {
-      if (m.type === 'sent') continue;
-      const h = m.handle || m.name;
-      if (!h || seen.has(h)) continue;
-      seen.add(h);
-      t.people.push({ name: m.name, handle: m.handle, icon: m.icon, url: m.url });
-    }
-    // Heb JIJ in deze draad iets gezegd? Bepaalt of hij als uitwisseling of als
-    // onbeantwoord bericht leest.
-    t.mine = t.messages.some((m) => m.type === 'sent');
-    // Waar gaat een antwoord uit deze draad heen? Twee paden, en ze sluiten
-    // elkaar uit: hangt de draad aan een post, dan antwoord je op het NIEUWSTE
-    // ontvangen bericht erin (dat is de parent van de thread) -- anders is het
-    // een direct bericht aan de tegenpartij.
-    const inkomend = t.messages.filter((m) => m.type !== 'sent');
-    const laatste = inkomend[inkomend.length - 1];
-    t.replyTo = {
-      interactionId: (laatste && laatste.interactionId) || null,
-      postSlug: (t.post && t.post.slug) || null,
-      actorUri: (laatste && (laatste.actorUri || laatste.url))
-        || (t.messages.find((m) => m.to_actor) || {}).to_actor
-        || (() => { try { return JSON.parse((t.messages.find((m) => m.to_actors) || {}).to_actors || '[]')[0] || null; } catch { return null; } })(),
-    };
-  }
-  return out;
-}
-
-export function buildCreate(base, site, post, opts = {}) {
-  const note = buildNote(base, site, post, opts);
-  return {
-    '@context': AP_CONTEXT,
-    id: note.id + '#create',
-    type: 'Create',
-    actor: actorId(base, site.slug),
-    published: note.published,
-    to: note.to,
-    cc: note.cc,
-    object: note,
-  };
-}
-
-
-/**
- * De outbox: wat deze actor heeft uitgebracht. Posts EN tracks (shaer-0nh,
- * stap 4).
- *
- * WAAROM HIER EN NIET IN EEN BEZORGING. Een kanaal-lezer HAALT de outbox op --
- * zo heb ik zelf Funkwhales kanaal uitgelezen. Een Create(Audio) ook naar de
- * inboxen van volgers duwen zou schade doen: Mastodon neemt Audio aan als
- * statustype, dus bij een album-post zou dezelfde muziek twee keer in hun
- * tijdlijn komen -- een keer als bijlage bij de Note, en dan nog N keer los.
- * De post is het bericht, de outbox is de discografie.
- *
- * Door elkaar op datum, nieuwste eerst, zodat de outbox één verhaal vertelt in
- * plaats van twee lijstjes achter elkaar.
- *
- * De tracks komen als ARGUMENT binnen, net als de posts, en worden hier
- * uitdrukkelijk NIET zelf opgehaald. De route beslist wie wat mag zien -- een
- * geblokkeerde bezoeker krijgt daar een lege outbox, en een bouwer die stiekem
- * zijn eigen database bevraagt zou dwars door die deur heen leveren.
- */
-/**
- * Een PAGINA van de outbox, in SQL (shaer-sk4).
- *
- * De outbox mengt twee bronnen: posts en open tracks, gevlochten op datum. Een
- * offset over die twee kan niet met twee losse queries -- je weet niet hoeveel
- * van elk er in pagina drie horen. Vandaar een UNION met de datum als sleutel,
- * daar de LIMIT/OFFSET overheen, en pas dan de rijen zelf ophalen.
- *
- * Wat er stond was geen paginering maar een KAP: de route haalde twintig posts
- * en hield daarvan twintig items over. Alles daarvoor was niet op een volgende
- * pagina maar helemaal onbereikbaar.
- *
- * @param {boolean} fanOnly  mag de lezer ook de fans-only posts zien?
- */
-export function outboxSlice(siteId, { fanOnly = false, offset = 0, limit = MAX_OUTBOX } = {}) {
-  const fanClause = fanOnly ? '' : 'AND (p.fan_only IS NULL OR p.fan_only = 0)';
-  const unie = `
-    SELECT 'post' AS soort, p.id AS id, ${isoSql('COALESCE(p.published_at, p.created_at)')} AS wanneer
-      FROM posts p WHERE p.site_id = ? AND p.status = 'published' ${fanClause}
-    UNION ALL
-    SELECT 'track', t.id, t.created_at
-      FROM audio_tracks t WHERE t.site_id = ? AND t.fedi_open = 1`;
-  let rijen = [], totaal = 0;
-  try {
-    totaal = db.prepare(`SELECT COUNT(*) n FROM (${unie})`).get(siteId, siteId).n;
-    rijen = db.prepare(`SELECT soort, id FROM (${unie}) ORDER BY wanneer DESC LIMIT ? OFFSET ?`)
-      .all(siteId, siteId, limit, Math.max(0, offset));
-  } catch { return { posts: [], tracks: [], totaal: 0 }; }
-
-  const postIds = rijen.filter((r) => r.soort === 'post').map((r) => r.id);
-  const trackIds = rijen.filter((r) => r.soort === 'track').map((r) => r.id);
-  const gaten = (n) => Array.from({ length: n }, () => '?').join(',');
-  const posts = postIds.length ? db.prepare(
-    // fan_only en ap_visibility MOETEN mee. buildNote adresseert hierop, en
-    // zonder deze twee kolommen is post.fan_only altijd undefined: elke
-    // fan-only post ging dan de outbox uit met to: as:Public, terwijl hij
-    // alleen aan vrienden geserveerd wordt. Een volger kreeg dus een
-    // vrienden-post met een publiek etiket erop, en die mag hij dan publiek
-    // boosten. Gevonden tijdens de FEP-1580 end-to-end test (shaer-fuyo).
-    // EN paid + excerpt, om exact dezelfde reden (Barts melding, 15-8). Zonder
-    // `paid` is post.paid hier `undefined`, dan slaat buildNote zijn redactie
-    // over en gaat de VOLLEDIGE tekst van een betaalde post de outbox uit. Zo
-    // kwam een post via een hub-actor gewoon te lezen. `excerpt` moet mee omdat
-    // de teaser daaruit komt; zonder dat veld valt hij terug op de eerste
-    // alinea van precies de tekst die verborgen hoort te blijven.
-    //
-    // Dit is een KOLOMMENLIJST, en die faalt stil: een vergeten kolom is
-    // `undefined` en niet een fout. Wie hier een veld toevoegt waar buildNote
-    // op beslist, moet het HIER ook toevoegen.
-    `SELECT id, slug, title, excerpt, content, cover_image_url, cover_video_url, nsfw, content_warning,
-            c2s_attachments, quote_json, embed_json, published_at, created_at,
-            fan_only, ap_visibility, paid, paid_min_cents
-       FROM posts WHERE id IN (${gaten(postIds.length)})`).all(...postIds) : [];
-  const tracks = trackIds.length ? db.prepare(
-    `SELECT ${TRACK_KOLOMMEN}
-       FROM audio_tracks t JOIN media m ON m.id = t.media_id
-      WHERE t.id IN (${gaten(trackIds.length)})`).all(...trackIds) : [];
-  return { posts, tracks, totaal };
-}
-
-export function buildOutbox(base, site, posts, tracks = [], { page = false, totalItems, alGesneden = false, rauweInhoud = false } = {}) {
-  const id = `${actorId(base, site.slug)}/outbox`;
-  const wanneer = (x) => Date.parse(x && x.published ? x.published : 0) || 0;
-  const items = [
-    ...(posts || []).map((p) => buildCreate(base, site, p, { rauweInhoud })),
-    // Eén zoekopdracht voor alle tracks samen, niet per stuk.
-    ...(() => {
-      const posts = (tracks || []).length && site.id ? trackHostPosts(site.id) : null;
-      return (tracks || []).map((r) => buildTrackCreate(base, site, r, { hostPosts: posts }));
-    })(),
-  ]
-    .sort((a, b) => wanneer(b) - wanneer(a))
-    .slice(0, alGesneden ? Infinity : MAX_OUTBOX);
-  // WAT HIER NOG NIET GEPAGINEERD IS, en dat hoort genoemd (shaer-sk4): deze
-  // lijst is al door de route op twintig rijen afgekapt, dus pagina 2 is leeg.
-  // Echt doorbladeren vraagt een LIMIT/OFFSET in SQL -- en dat is hier lastiger
-  // dan bij volgers, want posts en tracks worden op DATUM door elkaar gevlochten
-  // en komen uit twee tabellen. Dat vraagt een UNION met een offset erover, geen
-  // tweede slice. De vorm klopt nu wel: pagina 2 zegt eerlijk dat hij leeg is en
-  // biedt geen `next` aan, in plaats van pagina 1 nog eens te geven.
-  // GEPAGINEERD, ook al past alles op een pagina (Funkwhale, 11-8).
-  //
-  // Hun serializer weigerde onze outbox met "first: This field is required" en
-  // "last: This field is required". AS2 EIST ze niet -- een collectie mag zijn
-  // items inline dragen -- maar bijna iedereen pagineert, en een lezer die de
-  // paginaweg volgt liep hier dood. Dit is de eerste concrete reden die we
-  // hoorden waarom er niets van ons binnenkwam.
-  //
-  // De items blijven WEL inline op de wortel. Shaer bouwt zijn feed daaruit, en
-  // wie hem vandaag leest hoort er morgen niet voor te hoeven pagineren. Er is
-  // precies een pagina, dus first en last wijzen naar dezelfde.
-  return pagedCollection(id, items, { page, totalItems, alGesneden });
-}
-
-// Public callers get a count-only collection (privacy). The authenticated
-// account owner (a C2S bearer scoped to this site) gets the real actor URIs via
-// `items`, so their own client can build a friends list.
-export function buildFollowers(base, site, count, items = null, { page = false } = {}) {
-  const id = `${actorId(base, site.slug)}/followers`;
-  // count-only for the public; full for the owner
-  return pagedCollection(id, items || [], { totalItems: items ? items.length : (count || 0), page });
-}
-
-// The accounts this site follows — count only, mirroring buildFollowers. The spec lists
-// `following` as a standard actor property; Hubzilla/Friendica + crawlers expect it.
-export function buildFollowing(base, site, count, items = null, { page = false } = {}) {
-  const id = `${actorId(base, site.slug)}/following`;
-  // count-only for the public; full for the owner
-  return pagedCollection(id, items || [], { totalItems: items ? items.length : (count || 0), page });
-}
-
-// Pinned posts → the actor's `featured` collection. Mastodon reads this and shows
-// these as the "Featured" tab (pinned to the profile). Posts come ordered by pin
-// rank; embedded as full Notes so a remote server doesn't need extra fetches.
-export function buildFeatured(base, site, posts, { page = false } = {}) {
-  const id = `${actorId(base, site.slug)}/featured`;
-  const items = (posts || []).map((p) => buildNote(base, site, p));
-  return pagedCollection(id, items, { page });
-}
-
-// ── Playlist als AP-collectie (shaer-ayc, stap 1 van het Funkwhale-spoor) ──
-// Een playlist heeft, anders dan een album-als-tekstveld, een id — dus kan hij
-// een stabiele URI dragen en federeren. De vorm is bewust kaal AS2: een
-// OrderedCollection van Audio-objecten, dezelfde rijvorm die een post als
-// attachment meestuurt, zodat elke client die post-audio al speelt dit ook
-// speelt.
-//
-// De poortregel verandert hier NIET: alleen fedi_open-tracks staan erin, met
-// echte bestands-URL. Een gated track is niet "een rij zonder url" maar
-// afwezig — wie de collectie leest ziet het open deel en kan niet aftellen
-// hoeveel er achter de poort staat. totalItems telt daarom ook alleen het
-// open deel: een eerlijke telling over wat er werkelijk in de collectie staat,
-// niet over wat wij thuis in de kast hebben.
-
-// ── followers store (lazy stmts) ──────────────────────────────────
-let _insF, _updFDisp, _delF, _listF, _cntF;
-function fStmts() {
-  if (!_insF) {
-    _insF = db.prepare('INSERT OR IGNORE INTO ap_followers (slug, actor_uri, inbox, shared_inbox, name, handle, icon, created_at) VALUES (?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
-    _updFDisp = db.prepare('UPDATE ap_followers SET name = COALESCE(?, name), handle = COALESCE(?, handle), icon = COALESCE(?, icon) WHERE slug = ? AND actor_uri = ?');
-    _delF = db.prepare('DELETE FROM ap_followers WHERE slug = ? AND actor_uri = ?');
-    _listF = db.prepare('SELECT inbox, shared_inbox FROM ap_followers WHERE slug = ?');
-    _cntF = db.prepare('SELECT COUNT(*) n FROM ap_followers WHERE slug = ?');
-  }
-  return { ins: _insF, del: _delF, list: _listF, cnt: _cntF };
-}
-export function followerCount(slug) { return fStmts().cnt.get(slug).n; }
-
-// Followers with delivery health, for the management list. Never-delivered accounts
-// first, then oldest successful delivery first — i.e. the cleanup candidates on top.
-export function listFollowers(slug) {
-  return db.prepare(
-    `SELECT id, actor_uri, inbox, shared_inbox, created_at, last_delivery_at, last_error_at
-     FROM ap_followers WHERE slug = ?
-     ORDER BY (last_delivery_at IS NULL) DESC, last_delivery_at ASC, created_at ASC`
-  ).all(slug);
-}
-// Manually drop a follower after a check (a still-live account would have to re-follow).
-/**
- * Een volger verwijderen, en het hem ook LATEN WETEN (Robin, 21-8).
- *
- * Reject(Follow) is het standaardsignaal voor "je volgt me niet meer": de
- * andere kant ruimt de relatie dan op in plaats van te blijven denken dat hij
- * volgt. Zonder dit merkte de hub niets -- die bleef als volger in zijn eigen
- * boeken staan terwijl er nooit meer iets werd bezorgd.
- *
- * Verwijderen gaat altijd door; de melding is een gunst en mag mislukken.
- */
-export function removeFollower(slug, id) {
-  const rij = db.prepare('SELECT actor_uri FROM ap_followers WHERE slug = ? AND id = ?').get(slug, id);
-  const info = db.prepare('DELETE FROM ap_followers WHERE slug = ? AND id = ?').run(slug, id);
-  if (info.changes > 0 && rij && rij.actor_uri) meldNietLangerVolger(slug, rij.actor_uri);
-  return info.changes > 0;
-}
-
-/** Reject(Follow) naar een ex-volger; faalt stil, want de relatie is al weg. */
-export function meldNietLangerVolger(slug, actorUri) {
-  try {
-    const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-    const me = actorId(base, slug);
-    const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
-    if (!site) return;
-    const reject = {
-      '@context': AP_CONTEXT,
-      id: `${me}#reject-follow-${Date.now()}-${rid()}`,
-      type: 'Reject',
-      actor: me,
-      to: [actorUri],
-      object: { type: 'Follow', actor: actorUri, object: me },
-    };
-    deliverToActor(site, actorUri, reject)
-      .then((r) => console.log('[AP] Reject(Follow)', slug, '→', actorUri, r && r.delivered ? 'bezorgd' : 'niet bezorgd'))
-      .catch(() => {});
-  } catch { /* nooit blokkerend */ }
-}
-
-// Best cached display for an actor URI, across the caches Klonkt already fills:
-// followers (now with name/icon), following, interactions, timeline, mentions.
-// Falls back to a handle derived from the URI. Display info is not sensitive.
-export function actorDisplay(slug, uri) {
-  const ok = (r) => r && (r.name || r.icon);
-  try {
-    let r = db.prepare('SELECT name, handle, icon FROM ap_followers WHERE slug = ? AND actor_uri = ?').get(slug, uri);
-    if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
-    r = db.prepare('SELECT name, handle, icon FROM ap_following WHERE slug = ? AND actor_uri = ?').get(slug, uri);
-    if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
-    r = db.prepare('SELECT actor_name AS name, actor_handle AS handle, actor_icon AS icon FROM ap_interactions WHERE actor_uri = ? AND (actor_name IS NOT NULL OR actor_icon IS NOT NULL) ORDER BY created_at DESC LIMIT 1').get(uri);
-    if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
-    r = db.prepare('SELECT author_name AS name, author_handle AS handle, author_icon AS icon FROM ap_timeline WHERE author_uri = ? AND (author_name IS NOT NULL OR author_icon IS NOT NULL) LIMIT 1').get(uri);
-    if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
-    r = db.prepare('SELECT actor_name AS name, actor_handle AS handle, actor_icon AS icon FROM ap_mentions WHERE actor_uri = ? AND (actor_name IS NOT NULL OR actor_icon IS NOT NULL) ORDER BY created_at DESC LIMIT 1').get(uri);
-    if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
-  } catch { /* ignore */ }
-  return { name: null, handle: deriveHandle(uri), icon: null };
-}
-
-// FEP-9876: does this `Prefer` header ask for enriched (embedded) members?
-// Pure and testable; the route sets the response headers around it.
-export function prefersEnriched(preferHeader) {
-  return /(^|[,;\s])return=representation($|[,;\s])/i.test(String(preferHeader || ''));
-}
-
-// AS2 actor reference with display, for the owner C2S followers/following view.
-// preferredUsername = the local part of the handle; name = the set display name.
-export function buildActorRef(slug, uri) {
-  const d = actorDisplay(slug, uri);
-  const user = d.handle && d.handle[0] === '@' ? d.handle.slice(1).split('@')[0] : null;
-  const out = { id: uri, type: 'Person' };
-  if (d.name) out.name = d.name;
-  if (user) out.preferredUsername = user;
-  if (d.icon) out.icon = { type: 'Image', url: d.icon };
-  return out;
-}
-
-// The site's OWN display info in the same shape as `shaer:author` on timeline
-// entries. The owner's app reads its own posts from the outbox, which carried
-// no author info, so every card but your own had a byline (Robins melding,
-// 30-7: geen header van self op eigen posts).
-export function selfAuthor(base, site) {
-  const out = {
-    name: site.title || site.slug,
-    handle: `@${site.slug}@${String(base).replace(/^https?:\/\//, '')}`,
-    url: `${base}/${site.slug === site.primary_slug ? '' : 'user/' + encodeURIComponent(site.slug)}`,
-  };
-  if (site.profile_photo) {
-    out.icon = /^https?:/.test(site.profile_photo) ? site.profile_photo : `${base}${site.profile_photo.startsWith('/') ? '' : '/'}${site.profile_photo}`;
-  }
-  return out;
-}
-
-// Merge who-you-follow (ap_following, rich display) with who-follows-you (ap_followers,
-// delivery health) into ONE connections list, keyed by actor_uri. Each entry gets a
-// direction (following →, follower ←, mutual ↔) and, for accounts we deliver to, an
-// `unreachable` flag (never delivered, or last attempt failed after the last success) so
-// the view can split dead connections into their own section. Powers the Connect page.
-export function listConnections(slug) {
-  const byUri = new Map();
-  for (const f of listFollowing(slug)) {
-    byUri.set(f.actor_uri, {
-      actor_uri: f.actor_uri, name: f.name || null, handle: f.handle || null,
-      icon: f.icon || null, url: f.url || null, auto_boost: f.auto_boost ? 1 : 0,
-      status: f.status || null, following: true, follower: false,
-      last_delivery_at: null, last_error_at: null, follower_id: null,
-    });
-  }
-  for (const fo of listFollowers(slug)) {
-    const e = byUri.get(fo.actor_uri);
-    if (e) { e.follower = true; e.last_delivery_at = fo.last_delivery_at; e.last_error_at = fo.last_error_at; e.follower_id = fo.id; }
-    else byUri.set(fo.actor_uri, {
-      actor_uri: fo.actor_uri, name: null, handle: null, icon: null, url: null,
-      auto_boost: 0, status: null, following: false, follower: true,
-      last_delivery_at: fo.last_delivery_at, last_error_at: fo.last_error_at, follower_id: fo.id,
-    });
-  }
-  return [...byUri.values()].map((e) => {
-    e.direction = (e.following && e.follower) ? 'mutual' : (e.following ? 'following' : 'follower');
-    e.unreachable = e.follower && (!e.last_delivery_at || (!!e.last_error_at && (!e.last_delivery_at || e.last_error_at > e.last_delivery_at)));
-    return e;
-  });
-}
-
-// ── inbound interactions store (replies / likes / boosts) + our outbound replies ──
-let _insI, _delLA, _delReply, _listI, _getI, _insO, _listO, _getO;
-// ── moderation tombstones (ap_rejected_objects) ───────────────────
-// A reply the owner removed stays removed: its object URI is tombstoned and
-// checked at ingest AND by the thread-crawler (else thread-filling would
-// re-fetch it). Owner moderation acts on the LOCAL copy, so it also works for
-// private notes that authorize_interaction can't fetch (401/404).
-let _insRj, _hasRj;
-function rjStmts() {
-  if (!_insRj) {
-    _insRj = db.prepare('INSERT OR IGNORE INTO ap_rejected_objects (object_uri, post_id, reason) VALUES (?,?,?)');
-    _hasRj = db.prepare('SELECT 1 FROM ap_rejected_objects WHERE object_uri = ?');
-  }
-  return { ins: _insRj, has: _hasRj };
-}
-export function isRejectedObject(uri) {
-  if (!uri) return false;
-  try { return !!rjStmts().has.get(String(uri)); } catch { return false; }
-}
-// Owner removes an incoming reply: tombstone + delete. Tenancy-scoped: the
-// interaction's post must belong to the caller's site.
-export function rejectInteraction(site, interactionId, reason) {
-  if (!site || !site.slug) return { error: 'forbidden' };
-  const row = iStmts().getI.get(interactionId);
-  if (!row) return { error: 'not_found' };
-  const owns = db.prepare('SELECT 1 FROM posts WHERE id = ? AND site_id = (SELECT id FROM sites WHERE slug = ?)')
-    .get(row.post_id, site.slug);
-  if (!owns) return { error: 'forbidden' };
-  if (row.object_uri) { try { rjStmts().ins.run(row.object_uri, row.post_id, reason || 'removed by site owner'); } catch { /* non-fatal */ } }
-  db.prepare('DELETE FROM ap_interactions WHERE id = ?').run(interactionId);
-  console.log('[AP] interaction removed by owner', site.slug, row.object_uri || row.actor_uri);
-  return { ok: true, object_uri: row.object_uri || null, actor_uri: row.actor_uri || null };
-}
-// Stored URIs of an interaction (tenancy-scoped) → feed sendReport for flagging
-// from the local copy (works for private notes; no remote fetch needed to target).
-export function interactionReportTarget(site, interactionId) {
-  if (!site || !site.slug) return null;
-  const row = iStmts().getI.get(interactionId);
-  if (!row) return null;
-  const owns = db.prepare('SELECT 1 FROM posts WHERE id = ? AND site_id = (SELECT id FROM sites WHERE slug = ?)')
-    .get(row.post_id, site.slug);
-  if (!owns) return null;
-  return { objectUri: row.object_uri || null, actorUri: row.actor_uri || null };
-}
-
-// AP addressing → visibility: 'public' | 'unlisted' | 'followers' | 'direct'.
-// Mastodon-conventie: Public in `to` = public, Public in `cc` = unlisted, een
-// followers-collectie zonder Public = followers-only, anders direct (DM). Public
-// kan als volledige URI, 'as:Public' of 'Public' voorkomen (JSON-LD shorthands).
-export function noteVisibility(o) {
-  const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : []));
-  const isPub = (u) => u === PUBLIC || u === 'as:Public' || u === 'Public';
-  const to = arr(o && o.to).map(String);
-  const cc = arr(o && o.cc).map(String);
-  if (to.some(isPub)) return 'public';
-  if (cc.some(isPub)) return 'unlisted';
-  if ([...to, ...cc].some((u) => /\/followers\/?$/.test(u))) return 'followers';
-  return 'direct';
-}
-
-/**
- * Does this note belong in the home timeline (de Krant)?
- *
- * Only if it is a POST. A direct note is addressed to named people, so it is a
- * message: a plain DM, a ward's 🛟 help request (FEP-633c 5.2.1) or a
- * guardian's wave. Those are stored as mentions instead and surface in
- * Berichten and the Guardian PWA. A reply belongs to its thread, not the feed.
- */
-export function belongsInTimeline(o) {
-  if (!o || !o.id || o.inReplyTo) return false;
-  return noteVisibility(o) !== 'direct';
-}
-
-function iStmts() {
-  if (!_insI) {
-    _insI = db.prepare(`INSERT OR IGNORE INTO ap_interactions (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, actor_url, actor_icon, content, published, parent_uri, visibility, emoji_json, actor_emoji_json, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,${NU_ISO})`);
-    _delLA = db.prepare('DELETE FROM ap_interactions WHERE kind = ? AND post_id = ? AND actor_uri = ?');
-    _delReply = db.prepare("DELETE FROM ap_interactions WHERE kind = 'reply' AND object_uri = ?");
-    _listI = db.prepare('SELECT id, kind, object_uri, parent_uri, actor_uri, actor_name, actor_handle, actor_url, actor_icon, content, published, created_at, acted_boost, acted_like, visibility, emoji_json, actor_emoji_json FROM ap_interactions WHERE post_id = ? ORDER BY created_at ASC');
-    _getI = db.prepare('SELECT * FROM ap_interactions WHERE id = ?');
-    _insO = db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, language, attachments, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,${NU_ISO})`);
-    _listO = db.prepare('SELECT * FROM ap_outbox WHERE post_id = ? ORDER BY created_at ASC');
-    _getO = db.prepare('SELECT * FROM ap_outbox WHERE id = ?');
-  }
-  return { ins: _insI, delLA: _delLA, delReply: _delReply, list: _listI, getI: _getI, insO: _insO, listO: _listO, getO: _getO };
-}
-
-export function getInteractionById(id) { return iStmts().getI.get(id); }
-export function setInteractionBoosted(id, on) {
-  db.prepare('UPDATE ap_interactions SET acted_boost = ? WHERE id = ?').run(on ? 1 : 0, id);
-}
-export function setInteractionLiked(id, on) {
-  db.prepare('UPDATE ap_interactions SET acted_like = ? WHERE id = ?').run(on ? 1 : 0, id);
-}
-
-const localPostExists = (id) => { try { return !!db.prepare('SELECT 1 FROM posts WHERE id = ?').get(id); } catch { return false; } };
-// Extract our local post id from a note URL, but only if it's ours (base match).
-// One host, two spellings (Barts WebFinger-les, 2-8): a URL the client hands
-// back may carry the punycoded host (every URL parser silently punycodes)
-// while PUBLIC_BASE_URL carries the typed one. WHATWG URL does the IDNA, so
-// compare origins in ASCII and never the bytes the client happened to send.
-function asciiOrigin(u) {
-  try { const x = new URL(String(u)); return `${x.protocol}//${x.host}`.toLowerCase(); } catch { return null; }
-}
-function isOwnUrl(u) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base) return false;
-  const a = asciiOrigin(u);
-  return !!a && a === asciiOrigin(base);
-}
-function postIdFromNoteUrl(url, base) {
-  const s = String(url || '');
-  // ASCII origins, not startsWith: xn--zz9h.example IS 🩵.example, and a
-  // byte comparison read our own note as a stranger's.
-  if (base) { const a = asciiOrigin(s); if (!a || a !== asciiOrigin(base)) return null; }
-  const m = s.match(/\/ap\/notes\/([^/?#]+)/);
-  return m ? decodeURIComponent(m[1]) : null;
-}
-export function deriveHandle(actorUri) {
-  try { const u = new URL(actorUri); const seg = u.pathname.split('/').filter(Boolean).pop() || ''; return `@${seg}@${u.host}`; } catch { return String(actorUri || ''); }
-}
-function actorInfo(doc, actorUri) {
-  let host = ''; try { host = new URL(actorUri).host; } catch { /* keep empty */ }
-  const handle = doc && doc.preferredUsername ? `@${doc.preferredUsername}@${host}` : deriveHandle(actorUri);
-  const icon = doc && doc.icon ? (doc.icon.url || (Array.isArray(doc.icon) && doc.icon[0] && doc.icon[0].url)) : null;
-  const name = (doc && (doc.name || doc.preferredUsername)) || handle;
-  // Een AS2 `url` mag een ARRAY van Links zijn -- onze eigen buildActor doet
-  // dat (profiel + RSS), en een oudere consument stringde die array tot
-  // "[object Object],[object Object]" in de mention-hrefs van een hulpvraag
-  // (Barts vondst, 8-8). pickLink kiest de html-Link; de kale string blijft
-  // de gewone weg, en de actor-id de terugval.
-  const profiel = (doc && Array.isArray(doc.url))
-    ? ((pickLink(doc.url, (mt) => !mt || /html/i.test(mt)) || {}).href || safeUrl(doc.id || actorUri))
-    : safeUrl((doc && (doc.url || doc.id)) || actorUri);
-  return {
-    name,
-    handle,
-    url: profiel || null,
-    icon: safeUrl(icon) || null,
-    // FEP-9098 custom emojis in the display name (":shortcode:"), so the byline
-    // renders them. Only computed when the name actually has a shortcode.
-    emojis: /:[A-Za-z0-9_+-]+:/.test(name) ? actorNameEmojis(doc) : undefined,
-  };
-}
-
-// Map ":shortcode:" → image url from an actor doc's Emoji tags (for a custom-
-// emoji display name). Undefined when there are none.
-function actorNameEmojis(doc) {
-  const arr = doc && Array.isArray(doc.tag) ? doc.tag : (doc && doc.tag ? [doc.tag] : []);
-  const out = {};
-  for (const t of arr) {
-    if (!t || (Array.isArray(t.type) ? t.type[0] : t.type) !== 'Emoji' || typeof t.name !== 'string' || !t.icon) continue;
-    const u = t.icon.url || (Array.isArray(t.icon) && t.icon[0] && t.icon[0].url);
-    if (u) out[t.name] = u;
-  }
-  return Object.keys(out).length ? out : undefined;
-}
-
-// Given an inReplyTo note URL, find which local post the thread belongs to + the
-// note being replied to (parent), so a reply-to-a-comment can be nested.
-function findThreadTarget(inReplyTo, base) {
-  if (!inReplyTo) return null;
-  const seg = postIdFromNoteUrl(inReplyTo, base); // our /ap/notes/<id> segment (if ours)
-  if (seg && localPostExists(seg)) return { post_id: seg, parent_uri: inReplyTo };
-  if (seg) {
-    try { const o = db.prepare('SELECT post_id FROM ap_outbox WHERE id = ?').get(seg); if (o && o.post_id) return { post_id: o.post_id, parent_uri: inReplyTo }; } catch { /* ignore */ }
-  }
-  try { const row = db.prepare("SELECT post_id FROM ap_interactions WHERE object_uri = ? AND kind = 'reply' LIMIT 1").get(inReplyTo); if (row && row.post_id) return { post_id: row.post_id, parent_uri: inReplyTo }; } catch { /* ignore */ }
-  return null;
-}
-
-// Drop the leading @mention(s) a federated reply carries (the person being replied to),
-// so a comment reads "dope tekening ouwe" instead of "@jason@jasonhacky.nl dope …".
-// Keeps a leading <p> wrapper; handles mention <a> links and plain-text @user@domain.
-export function stripLeadingMentions(html) {
-  if (!html) return html;
-  let s = String(html);
-  s = s.replace(/^(\s*<p[^>]*>)?\s*(?:<a\b[^>]*>\s*@[^<]+<\/a>[  ]*)+/i, (m, p) => p || '');
-  s = s.replace(/^(\s*<p[^>]*>)?\s*(?:@[\w.-]+(?:@[\w.-]+)?[  ]+)+/i, (m, p) => p || '');
-  return s;
-}
-
-// View-ready threaded view of a post's fediverse activity (inbound replies +
-// our outbound replies, nested), plus like/boost counts.
-export function getInteractions(postId, base, site) {
-  const s = iStmts();
-  // Privacy: a followers-only or direct (DM) reply is addressed to people, not to the
-  // public web, so it must NOT render in the public thread. It still reaches the owner
-  // via notifications (post context + reference included there). Legacy rows without a
-  // visibility value are treated as public. Likes/boosts stay counted (count-only).
-  const rows = s.list.all(postId).filter((r) =>
-    r.kind !== 'reply' || !(r.visibility === 'followers' || r.visibility === 'direct'));
-  const baseClean = (base || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const postNoteId = baseClean ? `${baseClean}/ap/notes/${postId}` : null;
-  // Our own (outbound) replies show the SITE identity for everyone (not "You").
-  let host = ''; try { host = new URL(baseClean).host; } catch { /* ignore */ }
-  const siteName = (site && (site.title || site.slug)) || '';
-  const siteHandle = (site && site.slug && host) ? `@${site.slug}@${host}` : '';
-  const siteUrl = baseClean ? `${baseClean}/` : '';
-  const siteIcon = (site && site.profile_photo) || null;
-
-  // Wat JIJ met deze reacties deed komt uit de tussentabel, niet meer uit
-  // acted_* (shaer-ipb). Eén batch-lookup, want een drukke thread zou anders een
-  // N+1 worden. De sleutel loopt door canonicalReactionUri, precies zoals aan de
-  // schrijfkant -- staat dezelfde note toevallig ook in je tijdlijn, dan is het
-  // één feit en niet twee knoppen die los van elkaar aan kunnen staan.
-  const mijnSleutel = new Map();
-  for (const r of rows) {
-    if (r.kind === 'reply' && r.object_uri) mijnSleutel.set(r.object_uri, canonicalReactionUri(site && site.slug, r.object_uri));
-  }
-  const mijn = getReactionsFor(site && site.slug, [...mijnSleutel.values()]);
-  const mijnReactie = (uri) => mijn.get(mijnSleutel.get(uri)) || { liked: false, boosted: false };
-
-  const nodes = [];
-  for (const r of rows) {
-    if (r.kind !== 'reply') continue;
-    const ik = mijnReactie(r.object_uri);
-    nodes.push({
-      noteId: r.object_uri, parent: r.parent_uri || null, mine: false, id: r.id,
-      actor_uri: r.actor_uri,
-      actor_name: r.actor_name, actor_handle: r.actor_handle, actor_url: r.actor_url,
-      actor_icon: r.actor_icon, content: stripLeadingMentions(r.content), created_at: r.published || r.created_at,
-      emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json,   // FEP-9098 (thread render)
-      acted_boost: ik.boosted, acted_like: ik.liked,
-      children: [],
-    });
-  }
-  for (const o of s.listO.all(postId)) {
-    nodes.push({
-      noteId: baseClean ? `${baseClean}/ap/notes/${o.id}` : o.id, parent: o.in_reply_to || null,
-      mine: true, outboxId: o.id, content: stripLeadingMentions(o.content), created_at: o.created_at,
-      media: (() => { try { return o.attachments ? JSON.parse(o.attachments) : []; } catch { return []; } })(),
-      actor_name: siteName, actor_handle: siteHandle, actor_url: siteUrl, actor_icon: siteIcon,
-      children: [],
-    });
-  }
-
-  const byId = new Map(nodes.map((n) => [n.noteId, n]));
-  // Conversation partners per node (u02, the reply editor's mentions bar): the
-  // node's author plus the ancestor authors up the chain. Our own nodes are
-  // skipped (we do not mention ourselves), deduped by actor, capped at 8.
-  for (const n of nodes) {
-    const seen = new Set();
-    const list = [];
-    let cur = n, guard = 0;
-    while (cur && guard++ < 12 && list.length < 8) {
-      if (!cur.mine && cur.actor_uri && !seen.has(cur.actor_uri)) {
-        seen.add(cur.actor_uri);
-        list.push({
-          uri: cur.actor_uri,
-          url: cur.actor_url || cur.actor_uri,
-          handle: cur.actor_handle || deriveHandle(cur.actor_uri),
-        });
-      }
-      cur = cur.parent ? byId.get(cur.parent) : null;
-    }
-    n.participants = list;
-  }
-  const isTop = (n) => !n.parent || n.parent === postNoteId || !byId.has(n.parent);
-  const tops = [];
-  for (const n of nodes) {
-    if (isTop(n)) { tops.push(n); continue; }
-    let anc = n, guard = 0;
-    while (!isTop(anc) && guard++ < 12) anc = byId.get(anc.parent);
-    anc.children.push(n);
-  }
-  const byTime = (a, b) => new Date(a.created_at) - new Date(b.created_at);
-  tops.sort(byTime).forEach((t) => t.children.sort(byTime));
-
-  return {
-    thread: tops,
-    likeCount: rows.filter((r) => r.kind === 'like').length,
-    announceCount: rows.filter((r) => r.kind === 'announce').length,
-    total: nodes.length,
-  };
-}
-
-const slugFromActorUrl = (url) => { const m = String(url || '').match(/\/ap\/users\/([^/?#]+)/); return m ? decodeURIComponent(m[1]) : null; };
-// Which of OUR sites are named in a note's Mention tags? Only hrefs on our own base count
-// (an /ap/users/<slug> path on a remote host is someone else's actor), and the slug must be
-// an existing site. Deduped.
-export function localMentionSlugs(tags, base) {
-  if (!base) return [];
-  const out = [], seen = new Set();
-  for (const t of (Array.isArray(tags) ? tags : (tags ? [tags] : []))) {
-    if (!t || t.type !== 'Mention' || typeof t.href !== 'string') continue;
-    if (!t.href.startsWith(base + '/ap/users/')) continue;
-    const slug = slugFromActorUrl(t.href);
-    if (!slug || seen.has(slug)) continue; seen.add(slug);
-    try { if (db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(slug)) out.push(slug); } catch { /* ignore */ }
-  }
-  return out;
-}
-
-// ── Authorized fetch for a single Note (2-8) ─────────────────────
-// Who may read this post's Note over AP GET? 'public' needs nobody;
-// friends-only (fan_only, Shaer's DEFAULT) needs a verified follower;
-// 'direct' is addressed to people and is never served over a GET at all.
-export function noteAudience(post) {
-  if (!post) return 'direct';
-  if (post.ap_visibility === 'direct') return 'direct';
-  if (post.fan_only || post.ap_visibility === 'friends') return 'followers';
-  return 'public';
-}
-// A follower earns the friends-only Note; a blocked actor gets the same
-// nothing as a stranger (the standing rule: a blocked actor's signed fetch
-// earns the empty set, gated server-side at serialisation).
-export function mayReadNote(site, post, actorUri) {
-  const aud = noteAudience(post);
-  if (aud === 'public') return true;
-  if (aud === 'direct' || !site || !actorUri) return false;
-  // FEP-1580: dezelfde regel als in outboxAudience, en hier net zo hard nodig.
-  // De outbox geeft de LIJST vrij; zonder deze tak strandt de doelinstantie
-  // alsnog op elke losse Note die niet publiek is.
-  if (isMoveTarget(site.slug, actorUri)) return true;
-  try {
-    const blocked = db.prepare("SELECT 1 FROM ap_blocks WHERE slug = ? AND kind = 'actor' AND target = ?").get(site.slug, actorUri);
-    if (blocked) return false;
-    let host = null; try { host = new URL(actorUri).host; } catch { /* geen host, geen domein-block */ }
-    if (host) {
-      const dom = db.prepare("SELECT 1 FROM ap_blocks WHERE slug = ? AND kind = 'domain' AND target = ?").get(site.slug, host);
-      if (dom) return false;
-    }
-    return !!db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND actor_uri = ?').get(site.slug, actorUri);
-  } catch { return false; }
-}
-
-
-// ── Web push to the owner (docs/webpush-design.md, slice 3) ─────────
-// Fire-and-forget: a notification must never block or break inbox processing.
-function pushEvent(slug, event) {
-  try { Push.notifySite(slug, event).catch(() => {}); } catch { /* never throw */ }
-  wakeNews(slug);   // long-poll waiters (Robins verzoek, 31-7): same moments as push
-}
-
-// ── Long-poll on news (Robins verzoek, 31-7) ─────────────────────
-// The app holds GET /ap/users/:slug/inbox/wait open; the moment anything
-// push-worthy lands for that account (a message, a reply, a wave, a help
-// request) every waiter is woken and the app re-reads its feed. In-process
-// on purpose: one Klonkt is one process, and a waiter is one callback.
-const _newsWaiters = new Map();   // slug -> Set<cb>
-export function onNews(slug, cb) {
-  let set = _newsWaiters.get(slug);
-  if (!set) { set = new Set(); _newsWaiters.set(slug, set); }
-  set.add(cb);
-  return () => { set.delete(cb); if (!set.size) _newsWaiters.delete(slug); };
-}
-/**
- * Wachters op het Guardian-paneel (Barts opdracht, 9-8).
- *
- * APART VAN onNews, en dat is met opzet. `news` gaat over de tijdlijn; dit gaat
- * over alles wat een guardian te VERWERKEN krijgt -- een aanbod, een
- * volgverzoek, een gate-voorstel, een hulpvraag, een lapse. De guardianship-
- * module zendt daar al veertien soorten voor uit; die gingen alleen naar push,
- * en push kiest bewust maar een handvol. Het paneel moet ze allemaal weten.
- *
- * Een wachter wordt EEN keer gewekt en daarna vergeten: het antwoord dat volgt
- * is de nieuwe waarheid, en de client komt terug met een nieuwe wachter.
- */
-const _guardWaiters = new Map();   // slug -> Set<cb>
-export function onGuardian(slug, cb) {
-  let set = _guardWaiters.get(slug);
-  if (!set) { set = new Set(); _guardWaiters.set(slug, set); }
-  set.add(cb);
-  return () => { set.delete(cb); if (!set.size) _guardWaiters.delete(slug); };
-}
-export function wakeGuardian(slug) {
-  const set = _guardWaiters.get(slug);
-  if (!set || !set.size) return;
-  const cbs = [...set];
-  set.clear();
-  _guardWaiters.delete(slug);
-  for (const cb of cbs) { try { cb(); } catch { /* een wachter mag de rest nooit breken */ } }
-}
-
-export function wakeNews(slug) {
-  const set = _newsWaiters.get(slug);
-  if (!set || !set.size) return;
-  const cbs = [...set];
-  set.clear();
-  _newsWaiters.delete(slug);
-  for (const cb of cbs) { try { cb(); } catch { /* a waiter must never break the rest */ } }
-}
-// Path prefix for a site's pages. One instance is one owner, so the site
-// lives at the root; kept as a function because the push URLs read like
-// `${pushPrefix(slug)}/messages` all over this file.
-function pushPrefix() { return ''; }
-// Notification language: the site's content language (fallback: instance default).
-function pushLang(slug) {
-  try { const r = db.prepare('SELECT language FROM sites WHERE slug = ?').get(slug); return (r && r.language) || process.env.KLONKT_DEFAULT_LANG || 'nl'; } catch { return 'nl'; }
-}
-// Site slug, target URL and title for a post-scoped notification.
-function pushPostCtx(postId) {
-  try {
-    const r = db.prepare('SELECT p.slug AS post, p.title, s.slug AS site FROM posts p JOIN sites s ON s.id = p.site_id WHERE p.id = ?').get(postId);
-    if (!r) return null;
-    return { site: r.site, title: r.title || r.post, url: `${pushPrefix(r.site)}/${r.post}#fediverse` };
-  } catch { return null; }
-}
-
-// ── Op slot na een verhuizing (FEP-7628) ──────────────────────────
-//
-// Een verhuisd account serveert `movedTo` en is daarmee dood verklaard. Toch kon
-// je er gewoon op posten, volgen, liken en reageren, en dat federeerde vrolijk
-// de wereld in. Drie dingen gaan daar mis:
-//
-//   - Nieuwe posts krijgen een object-URI op een adres dat je hebt opgezegd. Die
-//     URI's overleven het domein niet, en de reacties erop ook niet.
-//   - Je volgers zijn al verhuisd, dus je post in het niets terwijl het lijkt of
-//     je post.
-//   - Een server die je movedTo ziet EN tegelijk verse activiteit van dat adres
-//     krijgt, krijgt tegenstrijdige signalen over de verhuizing.
-//
-// Daarom staat de poort op de UITGAANDE kant en niet op de knoppen: een
-// C2S-client (Shaer) praat rechtstreeks met deze functies en zou anders zo langs
-// een verborgen knop lopen. De UI volgt de poort, niet andersom.
-//
-// WAT DICHT GAAT: posten, reageren, volgen, liken, boosten, stemmen, en een
-// tweede verhuizing.
-// WAT OPEN BLIJFT: alles wat de wegwijzer draagt (de actor, webfinger, je
-// bestaande posts, de outbox), alles inkomend (reacties op oude posts blijven
-// binnenkomen en leesbaar), je eigen beheer (archief exporteren, volglijst
-// downloaden), en ontvolgen -- opruimen mag altijd.
-// Rapporteren blijft OOK open: dat is een veiligheidsklep, geen inhoud maken.
-//
-// OMKEERBAAR: `moved_to` leegmaken heft het slot op. Een verhuizing kan mislukken
-// en dan moet je terug kunnen.
-export function movedLock(site) {
-  const to = site && site.moved_to && /^https?:\/\//i.test(String(site.moved_to))
-    ? String(site.moved_to) : null;
-  return to ? { locked: true, movedTo: to } : { locked: false, movedTo: null };
-}
-
-/** Weigering in de vorm die de aanroepers al kennen: een object met `error`. */
-function movedRefusal(site, wat) {
-  const l = movedLock(site);
-  if (!l.locked) return null;
-  console.warn('[AP] geweigerd, dit account is verhuisd:', wat, '→', l.movedTo);
-  return { error: 'moved', movedTo: l.movedTo };
-}
-
-// Deliver a new post as Create(Note) to all followers' inboxes (fire-and-forget).
-// Needs PUBLIC_BASE_URL (absolute URLs); no-op without followers or base.
-export async function deliverCreate(site, post) {
-  if (movedLock(site).locked) { console.warn('[AP] Create niet bezorgd, account verhuisd:', site && site.slug); return; }
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !site || !site.slug) return;
-  // Resolve inline @user@host mentions → link them in the note + collect their inboxes, so a
-  // mentioned person is notified even if they don't follow us (Mastodon-standard mention).
-  const mres = await resolveMentionsInText(base, post.content || '');
-  let post2 = mres.inboxes.length ? { ...post, content: mres.html } : post;
-  // FEP-044f: does this post quote a fediverse object? Resolve it once, here,
-  // and remember it on the post, so buildNote (sync, also used by the outbox)
-  // never has to fetch. The quoted author's inbox joins the delivery set: that
-  // IS the notification.
-  const quoteInboxes = [];
-  // EERST BAKKEN, dan pas linken zoeken (shaer-k3f, gevonden op het toestel):
-  // firstExternalUrl leest <a href>-ankers, en de web-editor bakt die er bij
-  // het opslaan al in -- maar een post uit de APP is platte tekst waarin de
-  // URL nog geen anker is. Zonder deze bak zag het C2S-pad dus nooit een link
-  // en kreeg een app-post nooit een kaart, terwijl de preview hem net wel
-  // beloofd had.
-  const gebakken = bakePostContent(post2.content || '');
-  if (post2.quote_uri === undefined || post2.quote_uri === null) {
-    const q = await resolveOwnQuote(gebakken);
-    if (q) {
-      try { db.prepare('UPDATE posts SET quote_uri = ?, quote_actor = ? WHERE id = ?').run(q.uri, q.actor || null, post.id); } catch { /* ignore */ }
-      post2 = { ...post2, quote_uri: q.uri, quote_actor: q.actor || null };
-    }
-  }
-  // De kaart op de eigen post (shaer-k3f), langs dezelfde pijplijn als een
-  // binnenkomende: een fediverse-quote wordt een quote-snapshot, anders
-  // probeert de link een externe kaart. VOOR de vroege return hieronder, want
-  // ook een post zonder volgers hoort zijn kaart te krijgen -- de app leest
-  // hem uit de outbox, niet uit een bezorging. Best-effort en eenmalig: wat
-  // hier niet lukt blijft een kale link, precies wat het was.
-  if (!post2.quote_json && !post2.embed_json) {
-    try {
-      if (post2.quote_uri) {
-        const qj = await resolveQuoteByUri(post2.quote_uri);
-        if (qj) { db.prepare('UPDATE posts SET quote_json = ? WHERE id = ?').run(qj, post.id); post2 = { ...post2, quote_json: qj }; }
-      } else {
-        const ej = await resolveExternalEmbed(gebakken);
-        if (ej) { db.prepare('UPDATE posts SET embed_json = ? WHERE id = ?').run(ej, post.id); post2 = { ...post2, embed_json: ej }; }
-      }
-    } catch { /* een kaart is nooit een blokkade voor de post zelf */ }
-  }
-  if (post2.quote_actor) {
-    const a = await fetchActor(post2.quote_actor).catch(() => null);
-    const inbox = a && ((a.endpoints && a.endpoints.sharedInbox) || a.inbox);
-    if (inbox) quoteInboxes.push(inbox);
-  }
-  const followers = fStmts().list.all(site.slug);
-  const inboxes = [...new Set([...followers.map((f) => f.shared_inbox || f.inbox), ...mres.inboxes, ...quoteInboxes].filter(Boolean))];
-  if (!inboxes.length) return; // no followers, no one mentioned, no one quoted
-  const keys = getOrCreateKeys(site.slug);
-  const keyId = `${actorId(base, site.slug)}#main-key`;
-  const create = buildCreate(base, site, post2);
-  for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, create, keyId, keys.private_pem);
-}
-
-// On a new Follow, send that follower our most recent posts as Create so their
-// timeline shows our history (Mastodon does not backfill on follow). Oldest-first
-// so they sort into the follower's timeline at their original dates.
-async function backfillNewFollower(base, slug, inbox) {
-  if (!base || !slug || !inbox) return;
-  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
-  if (!site) return;
-  // Deze lijst filterde op fan_only maar NIET op paid, en haalde `paid` ook niet
-  // op -- dus stond post.paid op undefined, sloeg buildNote zijn redactie over,
-  // en duwden we bij ELKE nieuwe volger twintig posts de deur uit met de
-  // volledige tekst van de betaalde erbij. Een push, dus onherroepelijk: het
-  // staat daarna in hun inbox. Zelfde reden voor ap_visibility, dat hier
-  // helemaal ontbrak: een friends- of direct-post hoort niet in een backfill.
-  // (Barts melding, 15 augustus 2026.)
-  const recent = db.prepare(
-    `SELECT id, slug, title, excerpt, content, cover_image_url, cover_video_url, nsfw, content_warning,
-            c2s_attachments, published_at, created_at, fan_only, ap_visibility, paid, paid_min_cents
-     FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
-       AND IFNULL(ap_visibility, 'public') = 'public'
-     ORDER BY ${isoSql('COALESCE(published_at, created_at)')} DESC LIMIT 20`
-  ).all(site.id).reverse();
-  if (!recent.length) return;
-  const keys = getOrCreateKeys(slug);
-  const keyId = `${actorId(base, slug)}#main-key`;
-  for (const p of recent) {
-    try { await deliver(inbox, buildCreate(base, site, p), keyId, keys.private_pem); } catch { /* best-effort */ }
-    await new Promise((r) => setTimeout(r, 150));
-  }
-  console.log('[AP] backfilled', recent.length, 'posts to new follower of', slug);
-}
-
-// Tell followers a post is gone (Delete + Tombstone) so it's removed from their feeds.
-/**
- * Delete(Tombstone) voor een van onze EIGEN objecten, naar alle volgers.
- *
- * De romp staat apart omdat een post niet het enige is dat wij de draad op
- * sturen. Een track is een eersterangs Audio-object met een eigen id
- * (shaer-0nh), en die werd bij verwijderen nergens aangekondigd: de rij ging
- * weg, het object ging 404 en elke server die hem had geindexeerd hield hem
- * voor altijd. Op de hub kwam dat op 21-8 aan het licht als een track die naar
- * een dode URL wees.
- *
- * Het object-id komt van de aanroeper. Dat moet ook wel: bij verwijderen is de
- * rij vaak al weg, dus er valt niets meer op te zoeken.
- */
-export async function deliverObjectDelete(site, objectId) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !site || !site.slug || !objectId) return;
-  const followers = fStmts().list.all(site.slug);
-  if (!followers.length) return;
-  const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
-  const keys = getOrCreateKeys(site.slug);
-  const me = actorId(base, site.slug);
-  const del = {
-    '@context': AP_CONTEXT,
-    id: `${objectId}#delete-${Date.now()}-${rid()}`,
-    type: 'Delete',
-    actor: me,
-    to: [PUBLIC],
-    object: { id: objectId, type: 'Tombstone' },
-  };
-  for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, del, `${me}#main-key`, keys.private_pem);
-}
-
-export async function deliverDelete(site, post) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !post || !post.id) return;
-  return deliverObjectDelete(site, noteId(base, post.id));
-}
-
-/**
- * Zelfde voor een track. Roep dit aan VOOR het verwijderen van de rij, net als
- * bij een post: daarna is `id` er nog wel maar de context niet meer.
- */
-export async function deliverTrackDelete(site, trackId) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !site || !site.slug || !trackId) return;
-  return deliverObjectDelete(site, trackUri(base, site, trackId));
-}
-
-// Tell followers an already-published post changed (Update + edited Note) so
-// Mastodon refreshes the cached copy (e.g. after fixing content).
-export async function deliverUpdate(site, post) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !site || !site.slug || !post || !post.id) return;
-  const mres = await resolveMentionsInText(base, post.content || ''); // link mentions + collect inboxes
-  const post2 = mres.inboxes.length ? { ...post, content: mres.html } : post;
-  const followers = fStmts().list.all(site.slug);
-  const inboxes = [...new Set([...followers.map((f) => f.shared_inbox || f.inbox), ...mres.inboxes].filter(Boolean))];
-  if (!inboxes.length) return;
-  const keys = getOrCreateKeys(site.slug);
-  const me = actorId(base, site.slug);
-  const note = buildNote(base, site, post2);
-  note.updated = new Date().toISOString();
-  const update = {
-    '@context': AP_CONTEXT,
-    id: `${noteId(base, post.id)}#update-${Date.now()}-${rid()}`,
-    type: 'Update', actor: me, to: [PUBLIC], cc: note.cc,
-    object: note,
-  };
-  for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
-}
-
-// Tell followers the ACTOR changed (Update + Person) so Mastodon re-processes the
-// account AND re-fetches the featured (pinned) collection — there is no standard
-// "featured changed" activity, so this is how a pin/unpin propagates promptly.
-export async function deliverActorUpdate(site) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !site || !site.slug) return;
-  const followers = fStmts().list.all(site.slug);
-  if (!followers.length) return;
-  const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
-  const keys = getOrCreateKeys(site.slug);
-  const me = actorId(base, site.slug);
-  const update = {
-    '@context': AP_CONTEXT,
-    id: `${me}#update-${Date.now()}-${rid()}`,
-    type: 'Update', actor: me, to: [PUBLIC], cc: [`${me}/followers`],
-    object: buildActor(base, site),
-  };
-  for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
-}
-
-// Reliably set the pinned order on followers' instances via Add/Remove activities
-// (how Mastodon itself federates pins) — pushed to the inbox + processed immediately,
-// unlike the featured COLLECTION which Mastodon caches with sticky StatusPins.
-// Mastodon's Add skips an already-pinned status, so we REMOVE every pin first, wait,
-// then ADD in rank-DESCENDING order (rank 1 added LAST → newest StatusPin → shown first,
-// because Mastodon displays pins newest-first). `alsoRemove` = ids to unpin too.
-// Serialize pin-resyncs per site: two concurrent /save calls would otherwise interleave
-// their Remove -> wait -> Add sequences and scramble the StatusPin order on Mastodon. A
-// resync already in flight for a site coalesces later requests into ONE rerun after it
-// finishes (accumulating their extra unpins), so rapid saves don't pile up N full resyncs.
-const _pinResync = new Map(); // slug -> { promise, pending, pendingRemove:Set, site }
-export function resyncFeaturedPins(site, alsoRemove = []) {
-  if (!site || !site.slug) return Promise.resolve();
-  const slug = site.slug;
-  const running = _pinResync.get(slug);
-  if (running) {
-    running.pending = true;
-    running.site = site; // use the latest site object on the rerun
-    for (const id of alsoRemove) running.pendingRemove.add(id);
-    return running.promise;
-  }
-  const state = { promise: null, pending: false, pendingRemove: new Set(), site };
-  state.promise = (async () => {
-    let extra = alsoRemove;
-    for (;;) {
-      try { await doResyncFeaturedPins(state.site, extra); }
-      catch (e) { console.warn('[AP] pin resync failed:', e.message); }
-      if (!state.pending) break;
-      state.pending = false;
-      extra = [...state.pendingRemove];
-      state.pendingRemove = new Set();
-    }
-    _pinResync.delete(slug);
-  })();
-  _pinResync.set(slug, state);
-  return state.promise;
-}
-
-// The actual resync work — do NOT call directly; go through resyncFeaturedPins() above so
-// it stays serialized per site.
-async function doResyncFeaturedPins(site, alsoRemove = []) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !site || !site.slug) return;
-  const followers = fStmts().list.all(site.slug);
-  if (!followers.length) return;
-  const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
-  const keys = getOrCreateKeys(site.slug);
-  const me = actorId(base, site.slug);
-  const keyId = `${me}#main-key`;
-  const featured = `${me}/featured`;
-  const note = (id) => noteId(base, id);
-  const pinned = db.prepare(
-    `SELECT id FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
-       AND pinned IS NOT NULL AND pinned > 0
-     ORDER BY pinned DESC, ${isoSql('COALESCE(published_at, created_at)')} ASC LIMIT 20`
-  ).all(site.id);
-  const removeIds = [...new Set([...pinned.map((p) => p.id), ...alsoRemove])];
-  // 1. Remove every current pin so Mastodon can recreate them in order.
-  for (const id of removeIds) {
-    const rm = { '@context': AP_CONTEXT, id: `${me}#rm-${id}-${Date.now()}-${rid()}`, type: 'Remove', actor: me, object: note(id), target: featured, to: [PUBLIC] };
-    for (const inbox of inboxes) deliver(inbox, rm, keyId, keys.private_pem).catch(() => { /* best-effort */ });
-  }
-  if (!pinned.length) { console.log('[AP] unpinned all featured for', site.slug); return; }
-  await new Promise((r) => setTimeout(r, 5000)); // let the Removes land first
-  // 2. Add in rank-DESC order, gaps so each StatusPin gets an increasing created_at.
-  for (const p of pinned) {
-    const add = { '@context': AP_CONTEXT, id: `${me}#add-${p.id}-${Date.now()}-${rid()}`, type: 'Add', actor: me, object: note(p.id), target: featured, to: [PUBLIC], cc: [`${me}/followers`] };
-    for (const inbox of inboxes) deliver(inbox, add, keyId, keys.private_pem).catch(() => { /* best-effort */ });
-    await new Promise((r) => setTimeout(r, 2000));
-  }
-  console.log('[AP] resynced', pinned.length, 'featured pins for', site.slug);
-}
-
-// ── outbound replies (Klonkt → fediverse) ─────────────────────────
-const escHtml = (s) => String(s || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
-const toISO = (v) => { if (!v) return new Date().toISOString(); const s = String(v); const d = new Date(/[TZ]/.test(s) ? s : s.replace(' ', 'T') + 'Z'); return isNaN(d) ? new Date().toISOString() : d.toISOString(); };
-
-// Build one of OUR outbound reply Notes from an ap_outbox row.
-// Turn #hashtags in reply text into Mastodon-style hashtag links (clickable + federated).
-function linkHashtags(base, html) {
-  // Prefix: start / whitespace / '>' / opening bracket — "(#tag" is a tag too. NO quote
-  // chars in this class: a quote precedes attribute values (alt="#…"), which must not match.
-  return String(html || '').replace(/(^|[\s>([{])#([\p{L}\p{M}\p{N}_]+)/gu, (m, pre, tag) =>
-    `${pre}<a href="${base}/tag/${encodeURIComponent(tag.toLowerCase())}" class="mention hashtag" rel="tag">#${tag}</a>`);
-}
-// Auto-link bare http(s) URLs in already-safe HTML (federated copies). Splits on existing
-// <a>…</a> so a linked URL is never wrapped twice; requires start/whitespace/'>' before the
-// URL so attribute values (src="https://…") never match. Trailing sentence punctuation stays
-// outside the link (Mastodon-style).
-function linkUrls(html) {
-  const parts = String(html || '').split(/(<a\b[^>]*>[\s\S]*?<\/a>)/gi);
-  for (let i = 0; i < parts.length; i++) {
-    if (/^<a\b/i.test(parts[i])) continue; // already a link → leave as-is
-    parts[i] = parts[i].replace(/(^|[\s>([{])(https?:\/\/[^\s<]+?)([.,;:!?)\]»]*)(?=$|[\s<])/g,
-      (m, pre, url, trail) => `${pre}<a href="${url.replace(/"/g, '%22')}" rel="nofollow noopener" target="_blank">${url}</a>${trail}`);
-  }
-  return parts.join('');
-}
-// Linkify inline #hashtags and bare URLs in BODY html for on-site DISPLAY, using the
-// EXACT same rules as the federated copy (linkHashtags/linkUrls), so the website and the
-// Mastodon copy agree instead of the website showing raw text. Idempotent: existing
-// <a>…</a> (editor links, embeds, shortcode buttons) are split out and left untouched, so
-// nothing is double-wrapped. Pass base='' → root-relative /tag/<slug> links.
-export function linkifyBody(base, html) {
-  const withTags = String(html || '')
-    .split(/(<a\b[^>]*>[\s\S]*?<\/a>)/gi)
-    .map((seg) => (/^<a\b/i.test(seg) ? seg : linkHashtags(base, seg)))
-    .join('');
-  return linkUrls(withTags);
-}
-
-// Bake a post's raw source into its display HTML (the ActivityPub `source` model): done ONCE
-// at save and cached in posts.content_rendered, so page views serve it statically instead of
-// re-linkifying every render. Step 1 = #hashtags + bare URLs (cheap, no network). Step 2 will
-// resolve @mentions here too (webfinger once at save instead of per page view).
-export function bakePostContent(source) {
-  return linkifyBody('', source || '');
-}
-
-// Step 2: the full bake, incl. @mention links. Resolves @user@host via webfinger ONCE (the
-// same resolver the federated copy uses) and bakes the profile links into content_rendered,
-// so page views never do a per-view lookup. Unresolvable handles stay plain text; on any
-// failure it degrades to the sync #hashtag/URL bake. Async (webfinger) → callers run it off
-// the save response so the request never blocks on a slow/dead remote server.
-export async function bakePostContentWithMentions(source) {
-  const withHashUrls = bakePostContent(source);
-  try { const m = await resolveMentionsInText('', withHashUrls); return m.html; }
-  catch { return withHashUrls; }
-}
-
-// Extract the AP Hashtag tag objects from already-linked reply content.
-
-// Normalise a post's tags field (array, JSON-string, or comma-string) to an array.
-// normalizeTags en tagParts staan sinds shaer-38y in ap-core: music/ heeft ze
-// ook nodig en mag hier niet uit importeren.
-// A tag → { label, slug }. Multi-word tags become CamelCase (#LiveMusic) for the display
-// name (Mastodon hashtags can't contain spaces; CamelCase is the accessibility norm); the
-// slug/href stays lowercase ("livemusic").
-// Merge a post's tags field + the #hashtags linked inline in its body into one deduped
-// Hashtag tag list (with hrefs to our /tag page).
-// hashtagTags en buildHashtagList staan sinds shaer-38y in ap-core: music/
-// heeft dezelfde lijst nodig en mag hier niet uit importeren.
-
-// Extract Mention tag objects from already-linked content (class="u-url mention").
-function mentionTags(content) {
-  const tags = [], seen = new Set();
-  // The link href is the human profile URL; the actor URI (for the Mention tag) is in data-actor.
-  const re = /<a href="[^"]*" class="u-url mention" data-actor="([^"]+)">@([^<]+)<\/a>/gi;
-  let m;
-  while ((m = re.exec(content || ''))) {
-    const href = m[1];
-    if (seen.has(href)) continue; seen.add(href);
-    tags.push({ type: 'Mention', href, name: '@' + m[2] });
-  }
-  return tags;
-}
-// Resolve inline @user@domain mentions in reply/post text → link them (href = actor URI)
-// and collect the mentioned actors' inboxes so they get notified. Best-effort per mention.
-async function resolveMentionsInText(base, html) {
-  const inboxes = [];
-  const handles = new Set();
-  // Prefix also allows opening brackets — "(@user@host + me)" is a mention too (real-world
-  // miss: a bracketed mention federated as plain text and its target was never notified).
-  const re = /(^|[\s>([{])@([\p{L}\p{M}\p{N}_.-]+@[\p{L}\p{M}\p{N}.-]+)/gu;
-  let m;
-  while ((m = re.exec(html || ''))) handles.add(m[2]);
-  let out = String(html || '');
-  for (const h of handles) {
-    let actorUri = null;
-    try { actorUri = await webfingerResolve('@' + h); } catch { actorUri = null; }
-    if (!actorUri) continue;
-    const actor = await fetchActor(actorUri).catch(() => null);
-    const inbox = actor && ((actor.endpoints && actor.endpoints.sharedInbox) || actor.inbox);
-    if (inbox) inboxes.push(inbox);
-    const profileUrl = actorInfo(actor, actorUri).url || actorUri; // human profile page → the link href
-    const esc = h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
-    out = out.replace(new RegExp('(^|[\\s>([{])@' + esc + '(?![\\p{L}\\p{M}\\p{N}_.-])', 'gu'),
-      (full, pre) => `${pre}<a href="${profileUrl}" class="u-url mention" data-actor="${actorUri}">@${h}</a>`);
-  }
-  return { html: out, inboxes };
-}
-
-export function buildReplyNote(base, site, row) {
-  // Thin delegate: replies are built by buildNote (the single Note entry point) in reply mode.
-  return buildNote(base, site, row, { isReply: true });
-}
-
-// The account's own outbound notes (replies and direct messages) as AS2
-// Notes, newest first. The C2S inbox read serves these alongside the
-// timeline: without them your own reply existed everywhere EXCEPT in your
-// own app (Robins melding, 30-7: "replyen werkt nog niet"; het antwoord
-// stond op de server maar de app kreeg het nooit terug, dus je probeerde
-// het opnieuw en liep in de duplicate-guard).
-export function getSentNotes(base, site, limit = 60) {
-  return db.prepare('SELECT * FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC LIMIT ?')
-    .all(site.slug, limit)
-    .map((row) => buildReplyNote(base, site, row));
-}
-
-// Resolve one of our outbound reply Notes by id (for /ap/notes/:id fallback).
-export function getOutboxNote(base, id) {
-  const row = iStmts().getO.get(id);
-  if (!row) return null;
-  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(row.site_slug);
-  if (!site) return null;
-  return buildReplyNote(base, site, row);
-}
-
-// The direct-note leg (ward call-for-help) lives in the guardianship module
-// (src/services/guardianship/delivery.js); wired with our AP helpers at the
-// bottom of this file. Re-exported so every existing caller keeps working.
-export const c2sVisibility = Guardianship.c2sVisibility;
-export const deliverDirectNote = Guardianship.deliverDirectNote;
-
-// Send a reply FROM this site to a remote actor (in reply to their inbound reply).
-// `parent` = an ap_interactions row (actor_uri, actor_url, actor_handle, object_uri).
-/**
- * Een gate-voorstel de deur uit (FEP-633c 5.6, shaer-8ru).
- *
- * STOND IN routes/guardian.js en kon daar alleen door de PWA aangeroepen worden.
- * De apps moeten hetzelfde kunnen, en een tweede implementatie ernaast zou een
- * tweede weg naar hetzelfde besluit zijn -- precies de fout die we vandaag bij
- * de antwoordpoort hebben rechtgezet, toen de innamepoort alleen in C2S bleek te
- * zitten en het webpad eromheen liep. Een pad dus.
- *
- * EEN WEG, waar de ward ook woont (Robins regel, 29-7): voorstellen over de
- * lijn en de server van de ward laat tellen. Co-locatie verandert alleen het
- * transport -- deliverToActor lust een lokale ontvanger terug door dezelfde
- * inbox. De oude kortsluiting boekte de stem hier meteen, en zo bleef het
- * remote-pad een maand stuk zonder dat iemand het merkte.
- */
-export function proposeGate(site, wardUri, feature, allow) {
-  const uri = String(wardUri || '').trim();
-  if (!uri) return { status: 400, error: 'empty_uri' };
-  if (!Guardianship.gated.featureColumn(feature)) return { status: 400, error: 'unknown_feature' };
-  // Alleen een guardian van dit kind. Zonder deze regel zou iedereen met een
-  // token een instelling van een vreemd kind kunnen aanvragen.
-  if (!Guardianship.listWards(site.slug).some((w) => w.other_uri === uri)) {
-    return { status: 403, error: 'not_your_ward' };
-  }
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const me = actorId(base, site.slug);
-  const offerId = `${me}/gated/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
-  const offer = Guardianship.gated.buildGatedOffer(offerId, me, uri, feature, allow);
-  // Ons eigen spoor van wat we stuurden: de server van de ward antwoordt op deze
-  // Offer zodra het besluit valt, en dat antwoord heeft een rij nodig om in te
-  // landen. Het is ook het enige waardoor het scherm van de voorsteller meer kan
-  // zeggen dan een knoptekst.
-  Guardianship.gated.recordSent(offerId, site.slug, uri, feature, allow);
-  deliverToActor(site, uri, offer).catch(() => { /* queued, best-effort */ });
-  const localSlug = (base && uri.startsWith(`${base}/`)) ? uri.replace(/\/+$/, '').split('/').pop() : null;
-  const progress = localSlug ? Guardianship.gated.gatedProgress(localSlug, feature) : null;
-  return { status: 200, ok: true, allow, state: 'open', offerId, ...(progress || { federated: true }) };
-}
-
-export async function deliverReply(site, { postId, postSlug, parent, text, html, language, attachments, mentions, visibility }) {
-  const _mv = movedRefusal(site, 'reply'); if (_mv) return _mv;
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  // Rich replies: `html` is the reply editor's HTML (sanitized here); `text` is
-  // the plain-text fallback (no-JS path, C2S `source`). Either may carry the reply.
-  const richClean = html ? HtmlSanitizerService.sanitize(String(html)) : '';
-  const rich = richClean && HtmlSanitizerService.toPlainText(richClean).trim() ? richClean : '';
-  // Attachments: only OUR OWN uploads (/media/... paths, no remote URLs — the
-  // upload route is the sole producer), image/audio/video only, max 4.
-  const media = (Array.isArray(attachments) ? attachments : [])
-    .filter((a) => a && typeof a.url === 'string' && /^\/media\/[\w./-]+$/.test(a.url)
-      && /^(image|audio|video)\//.test(String(a.mediaType || '')))
-    .slice(0, 4)
-    .map((a) => ({ url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) }));
-  // A media-only reply (no text) is a valid reply.
-  if (!base || !site || !site.slug || !parent || (!String(text || '').trim() && !rich && !media.length)) return null;
-  // DE POORT STAAT HIER en niet alleen in de outbox (shaer-r4c). routes/posts.js
-  // roept deliverReply op drie plekken rechtstreeks aan -- de eigen webinterface
-  // van Klonkt gaat dus nooit langs ingestOutboxActivity. Een poort die alleen in
-  // C2S staat is een poort met een deur ernaast.
-  //
-  // Dit is het knooppunt dat beide paden delen. De reddingsboei komt hier niet
-  // langs: een hulpvraag is altijd direct en loopt via deliverDirectNote, dus de
-  // boei blijft open zonder dat daar een uitzondering voor nodig is.
-  {
-    const isWard = (() => { try { return Guardianship.listGuardians(site.slug).length > 0; } catch { return false; } })();
-    if (!Guardianship.wardGateAllowed(site.gate_replies, isWard)) return null;
-  }
-  const me = actorId(base, site.slug);
-  // u02, the mentions bar: `mentions` undefined = legacy behavior (mention the
-  // parent author). An ARRAY (possibly empty) = the kept conversation partners
-  // exactly as the bar shows them; the mention prefix, the Mention tags (via
-  // mentionTags over the content) and the delivery targets all follow it.
-  const kept = Array.isArray(mentions)
-    ? mentions
-      .filter((m) => m && typeof m.uri === 'string' && /^https?:\/\//i.test(m.uri))
-      .slice(0, 8)
-      .map((m) => ({
-        uri: m.uri,
-        url: (typeof m.url === 'string' && /^https?:\/\//i.test(m.url)) ? m.url : m.uri,
-        handle: String(m.handle || deriveHandle(m.uri)).slice(0, 120),
-      }))
-    : null;
-  const mentionAnchor = (uri, url, h) => {
-    const disp = h && h[0] === '@' ? h : '@' + (h || '');
-    return `<a href="${escHtml(url || uri)}" class="u-url mention" data-actor="${escHtml(uri)}">${escHtml(disp)}</a> `;
-  };
-  const handle = parent.actor_handle || deriveHandle(parent.actor_uri);
-  const mention = kept
-    ? kept.map((k) => mentionAnchor(k.uri, k.url, k.handle)).join('')
-    : (parent.actor_uri ? mentionAnchor(parent.actor_uri, parent.actor_url, handle) : '');
-  // Who the stored reply is "to": the parent when kept, else the first kept chip.
-  const parentKept = !kept || kept.some((k) => k.uri === parent.actor_uri);
-  const toActorUri = parentKept ? (parent.actor_uri || null) : (kept[0] ? kept[0].uri : null);
-  const toHandle = parentKept ? handle : (kept[0] ? kept[0].handle : null);
-  let content;
-  let mres;
-  if (rich) {
-    // Same enrichment pipeline as the plain path (mentions/hashtags/URLs), on
-    // sanitized editor HTML. The parent mention goes inline into the first
-    // paragraph (Mastodon convention), or becomes its own leading one.
-    mres = await resolveMentionsInText(base, rich);
-    const processed = linkUrls(linkHashtags(base, mres.html));
-    if (processed.startsWith('<p>')) {
-      content = processed.replace('<p>', `<p>${mention}`);            // inline in the first paragraph
-    } else if (/^<(blockquote|ul|ol|pre|h[1-6]|div|hr)\b/i.test(processed)) {
-      content = `<p>${mention}</p>${processed}`;                      // block content: own leading paragraph
-    } else {
-      content = `<p>${mention}${processed}</p>`;                      // bare inline text: one paragraph together
-    }
-  } else {
-    const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
-    mres = await resolveMentionsInText(base, body); // link inline @mentions + collect their inboxes
-    content = `<p>${mention}${linkUrls(linkHashtags(base, mres.html))}</p>`;
-  }
-  const replyLang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null;
-  // Dedup: skip if the exact same reply was already sent (double-submit guard).
-  // Attachments count toward "the same": two media-only replies share content.
-  const mediaJson = media.length ? JSON.stringify(media) : null;
-  // A duplicate is idempotent success, not an error: it answers with the
-  // EXISTING id. Returning without one made the C2S ingest say 502
-  // reply_failed on a double-submit (Robins schermafdruk, 30-7), so a retry
-  // of a reply the app never showed looked like the reply itself failing.
-  const dup = db.prepare('SELECT id FROM ap_outbox WHERE site_slug = ? AND IFNULL(in_reply_to, \'\') = ? AND content = ? AND IFNULL(attachments, \'\') = IFNULL(?, \'\') LIMIT 1')
-    .get(site.slug, parent.object_uri || '', content, mediaJson);
-  if (dup) { console.log('[AP] outreply skipped (duplicate)'); return { duplicate: true, id: dup.id, delivered: 0 }; }
-  const id = crypto.randomUUID();
-  iStmts().insO.run(id, site.slug, postId, postSlug || null, parent.object_uri || null, toActorUri, toHandle, content, replyLang, mediaJson);
-  // Followers-only reply (shaer detail-view): mark the row so buildNote drops
-  // Public from cc. Default (undefined/'public'/'quiet') stays quiet-public.
-  if (visibility === 'friends') { try { db.prepare('UPDATE ap_outbox SET visibility = ? WHERE id = ?').run('friends', id); } catch { /* ignore */ } }
-  const row = iStmts().getO.get(id);
-  const note = buildReplyNote(base, site, row);
-  const create = {
-    '@context': AP_CONTEXT,
-    id: note.id + '#create', type: 'Create', actor: me,
-    published: note.published, to: note.to, cc: note.cc, object: note,
-  };
-  const keys = getOrCreateKeys(site.slug);
-  const keyId = `${me}#main-key`;
-  const inboxes = new Set();
-  // Everyone the mentions bar kept gets pinged; legacy path = the parent only.
-  const mentionTargets = kept ? kept.map((k) => k.uri) : (parent.actor_uri ? [parent.actor_uri] : []);
-  for (const uri of mentionTargets) {
-    const a = await fetchActor(uri).catch(() => null);
-    if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox);
-  }
-  if (parent.threadInbox) inboxes.add(parent.threadInbox); // back-compat (single)
-  (parent.threadInboxes || []).forEach((i) => inboxes.add(i)); // whole ancestor chain
-  for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
-  mres.inboxes.forEach((i) => inboxes.add(i)); // people @mentioned inline in the reply
-  inboxes.delete(`${me}/inbox`);       // never deliver to ourselves (already in ap_outbox)
-  inboxes.delete(`${base}/ap/inbox`);  // (our own shared inbox) → avoids a self-duplicate
-  let delivered = 0;
-  for (const inbox of [...inboxes].filter(Boolean)) {
-    let ok = false;
-    try { const st = await deliver(inbox, create, keyId, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; }
-    if (ok) delivered++;
-    else enqueueDelivery(site.slug, inbox, create); // durable: retry a briefly-offline recipient (was silently dropped)
-  }
-  console.log('[AP] outreply', site.slug, '→', parent.actor_uri, 'delivered', delivered);
-  return { id, content, delivered };
-}
-
-// attributedTo may be a string, an object {id}, or an ARRAY — e.g. a PeerTube Video is
-// attributed to [Person (account), Group (channel)]. Pick a usable actor URI (prefer Person).
-function actorUriOf(att) {
-  if (!att) return null;
-  if (typeof att === 'string') return att;
-  if (Array.isArray(att)) {
-    const person = att.find((a) => a && typeof a === 'object' && a.type === 'Person' && a.id);
-    if (person) return person.id;
-    for (const a of att) { if (typeof a === 'string') return a; if (a && a.id) return a.id; }
-    return null;
-  }
-  return att.id || null;
-}
-
-// Resolve a remote post URL (any fediverse/Klonkt post) into a reply target.
-// Returns a parent-shaped object usable by deliverReply(), or null.
-// The server's own note, built straight from the DB. resolveRemoteNote used
-// to fetch EVERYTHING over HTTPS, including notes living right here: a
-// hairpin fetch fails on home setups (a Klonkt on a Mac behind a tunnel), the
-// /ap/notes route rightly hides friends-only posts, and a punycode-spelled
-// own URL read as remote on a byte comparison. For the authenticated C2S
-// caller none of those walls apply; the DB is one prepare() away.
-// `forSlug` is that caller: only the post's own site gets its non-public
-// notes on this shortcut (public ones anyone, same as the route serves).
-function localNoteObject(url, forSlug) {
-  if (!isOwnUrl(url)) return null;
-  const m = String(url).match(/\/ap\/notes\/([^/?#]+)/);
-  if (!m) return null;
-  const id = decodeURIComponent(m[1]);
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const post = db.prepare("SELECT * FROM posts WHERE id = ? AND status = 'published'").get(id);
-  if (post) {
-    const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id);
-    if (!site) return null;
-    const nonPublic = post.fan_only || post.ap_visibility === 'friends' || post.ap_visibility === 'direct';
-    if (nonPublic && (!forSlug || forSlug !== site.slug)) return null;
-    return buildNote(base, site, post);
-  }
-  return getOutboxNote(base, id);   // our own outbound replies
-}
-// The own actor document, same shortcut, same reason.
-function localActorObject(uri) {
-  if (!isOwnUrl(uri)) return null;
-  const m = String(uri).match(/\/ap\/users\/([^/?#]+)/);
-  const site = m ? db.prepare('SELECT * FROM sites WHERE slug = ?').get(decodeURIComponent(m[1])) : null;
-  return site ? buildActor((process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''), site) : null;
-}
-
-// ── De thread onder een post (shaer-tqz) ───────────────────────────
-//
-// Klonkt is hier een TOLK, geen archief (Barts besluit, 7-8): de antwoorden
-// worden opgehaald op het moment dat iemand kijkt en daarna weer vergeten.
-// Geen tabel, geen migratie -- wie replies bewaart van elke post die iemand
-// tegenkomt, laat de omvang van zijn database bepalen door surfgedrag. Dat is
-// de AFWIJKING, niet de norm: Mastodon serveert /context uit zijn eigen
-// database, en dat verdient zich daar terug omdat honderden mensen de cache
-// delen. Een Klonkt-instance is de server van één persoon.
-//
-// Waarom dit niet in de app kan: de replies-collectie van een vreemde server
-// eist in secure mode een ONDERTEKEND verzoek, en de sleutel staat hier en kan
-// hier niet weg. Voor de opgaande inReplyTo-keten komt de app weg met een
-// ongetekende GET (mist er een, jammer); voor een thread van dertig is "de
-// helft doet het niet" geen resultaat.
-//
-// Je krijgt hier NOOIT de hele thread: een replies-collectie bevat alleen wat
-// die ene server gezien heeft. De UI hoort "wat de bron weet" te tonen en geen
-// volledigheid te suggereren.
-// NIET hetzelfde als maybeCrawlThread verderop: die kruipt de thread onder je
-// EIGEN posts af en bewaart de antwoorden in ap_interactions (dat zijn de
-// jouwe, die horen te blijven). Dit hier is voor een post van een ANDER die je
-// tegenkomt, en bewaart niets.
-const THREAD_VIEW_LIMIT = 30;
-const THREAD_VIEW_TTL_MS = 120_000;
-const THREAD_VIEW_CACHE_MAX = 200;
-const threadViewCache = new Map();   // `${slug}|${uri}` -> { at, out } -- geheugen, weg bij herstart
-
-/** Eén pagina items uit een AS2-collectie, welke spelling hij ook koos. */
-function collectionItems(coll) {
-  if (!coll || typeof coll !== 'object') return [];
-  const arr = coll.orderedItems || coll.items;
-  return Array.isArray(arr) ? arr : [];
-}
-
-/**
- * De directe antwoorden op één note, genormaliseerd voor de C2S-lezer.
- *
- * ALLEEN ophalen en normaliseren; de poorten zitten in de route. De
- * kringfilter (de dichte stand van shaer:externalThreads) woont in
- * filterThreadToCircle en de beeld/muziek/emoji-poorten in
- * gateAttachments/stripEmojiTags -- per verzoek, buiten deze cache om, want de
- * stand van een poort mag hier niet twee minuten bevriezen. Geblokkeerde
- * actors zijn een andere categorie en verdwijnen WEL hier, zonder telling:
- * een blokkade is onzichtbaar, ook als getal.
- */
-export async function getThread(slug, objectUri) {
-  const key = `${slug}|${objectUri}`;
-  const hit = threadViewCache.get(key);
-  if (hit && Date.now() - hit.at < THREAD_VIEW_TTL_MS) return hit.out;
-
-  // De status waarmee de BRON antwoordde op de note zelf. 401/403/404/410 is
-  // een besluit van die server (niet gedeeld, of weg); alles daarbuiten -- ook
-  // een stuk netwerk dat wegviel -- is een storing. De route moet dat verschil
-  // kunnen zeggen, anders wijst de melding naar de verkeerde partij.
-  let sourceStatus = 0;
-  const get = (u) => localNoteObject(u, slug) || signedGetJson(slug, u, (st) => { sourceStatus = st; });
-  const note = await get(objectUri);
-  const repliesRef = note && note.replies;
-  let coll = null;
-  if (typeof repliesRef === 'string') coll = await signedGetJson(slug, repliesRef);
-  else if (repliesRef && typeof repliesRef === 'object') {
-    coll = collectionItems(repliesRef).length || repliesRef.first ? repliesRef
-      : (repliesRef.id ? await signedGetJson(slug, repliesRef.id) : repliesRef);
-  }
-  // De pagina-wandeling van collectReplyItems, maar met behoud van INLINE
-  // objecten (die niet opnieuw opgehaald hoeven). Niet "de eerste pagina":
-  // Mastodon serveert `first` als inline-pagina met LEGE items en een `next`
-  // waar de antwoorden echt staan -- wie alleen de eerste pagina leest, ziet
-  // op elke Mastodon-post een leeg gesprek. Dat was precies Barts melding
-  // (8-8, reacties op een vreemde post). Eigen posts maskeerden het: die
-  // gaan door de lokale kortsluiting en hebben orderedItems meteen vol.
-  let items = [];
-  let node = coll;
-  if (node && node.first && !collectionItems(node).length) {
-    node = typeof node.first === 'string' ? await signedGetJson(slug, node.first) : node.first;
-  }
-  let pages = 0;
-  while (node && pages++ < 3 && items.length < THREAD_VIEW_LIMIT) {
-    items.push(...collectionItems(node));
-    if (!node.next) break;
-    node = typeof node.next === 'string' ? await signedGetJson(slug, node.next) : node.next;
-  }
-  items = items.slice(0, THREAD_VIEW_LIMIT);
-
-  // Alles tegelijk in plaats van om de beurt: dertig vreemde servers na elkaar
-  // afwachten is een halve minuut kijken naar een spinner.
-  const objs = await Promise.all(items.map(async (it) => {
-    const o = typeof it === 'string' ? await get(it) : (it && it.object && typeof it.object === 'object' ? it.object : it);
-    return (o && o.id && o.attributedTo) ? o : null;
-  }));
-
-  const kept = [];
-  for (const o of objs) {
-    if (!o) continue;
-    const actorUri = actorUriOf(o.attributedTo);
-    if (!actorUri || isBlockedAny(actorUri)) continue;   // een blokkade telt niet mee
-    kept.push({ o, actorUri });
-  }
-
-  // Bylines: één fetch per unieke auteur, niet één per antwoord.
-  const authors = new Map();
-  await Promise.all([...new Set(kept.map((k) => k.actorUri))].map(async (uri) => {
-    authors.set(uri, localActorObject(uri) || await signedGetJson(slug, uri).catch(() => null));
-  }));
-
-  const notes = kept.map(({ o, actorUri }) => ({
-    id: o.id,
-    type: 'Note',
-    // De ingesloten actor (shaer-nmw): de byline hoort in attributedTo, waar
-    // elke AP-lezer hem zoekt, en niet in een eigen property ernaast.
-    attributedTo: actorObject(actorUri, actorInfo(authors.get(actorUri), actorUri)),
-    inReplyTo: (typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id)) || objectUri,
-    content: HtmlSanitizerService.sanitize(String(o.content || '').slice(0, 50_000)),
-    url: safeUrl(typeof o.url === 'string' ? o.url : (o.url && o.url.href)) || undefined,
-    published: typeof o.published === 'string' ? o.published : undefined,
-    sensitive: !!o.sensitive,
-    summary: (contentWarning(o) || '').slice(0, 500) || undefined,
-    attachment: (() => {
-      const arr = Array.isArray(o.attachment) ? o.attachment : (o.attachment ? [o.attachment] : []);
-      const out = arr.map((a) => ({ type: 'Document', mediaType: (a && a.mediaType) || undefined, url: safeUrl(a && a.url), name: (a && typeof a.name === 'string') ? a.name.slice(0, 1500) : undefined }))
-        .filter((a) => a.url);
-      return out.length ? out.slice(0, 8) : undefined;
-    })(),
-    // FEP-9098: de custom emoji van het antwoord (":shortcode:" -> plaatje).
-    // Zonder deze tags rendert een reply van een Mastodon-account zijn emoji
-    // als kale tekst (Barts punt, 8-8). Alleen naam + geschoond icoon-adres
-    // gaan door; de rest van de vreemde tag-array blijft achter.
-    tag: (() => {
-      const j = extractEmojiTags(o.tag);
-      if (!j) return undefined;
-      const out = JSON.parse(j)
-        .map((t) => ({ type: 'Emoji', name: t.name, icon: { type: 'Image', url: safeUrl(t.icon && (t.icon.url || (Array.isArray(t.icon) && t.icon[0] && t.icon[0].url))) } }))
-        .filter((t) => t.icon.url)
-        .slice(0, 30);
-      return out.length ? out : undefined;
-    })(),
-  })).sort((a, b) => String(a.published || '').localeCompare(String(b.published || '')));
-
-  const out = { notes, found: !!note, sourceStatus };
-  threadViewCache.set(key, { at: Date.now(), out });
-  if (threadViewCache.size > THREAD_VIEW_CACHE_MAX) {
-    const oldest = [...threadViewCache.entries()].sort((a, b) => a[1].at - b[1].at)[0];
-    if (oldest) threadViewCache.delete(oldest[0]);
-  }
-  return out;
-}
-
-/**
- * De thread gefilterd op de kring die de guardians al kennen (gevolgd of
- * volgend) -- de dichte stand van shaer:externalThreads. PER VERZOEK, buiten de
- * threadcache om: een poort die de guardians net dichtzetten mag niet nog twee
- * minuten open nawerken uit een cache. Wat er buiten valt wordt GETELD, nooit
- * stil weggelaten.
- */
-export function filterThreadToCircle(slug, notes) {
-  const circle = new Set();
-  try { for (const r of db.prepare("SELECT actor_uri FROM ap_following WHERE slug = ? AND status = 'accepted'").all(slug)) circle.add(r.actor_uri); } catch { /* geen tabel */ }
-  try { for (const r of db.prepare('SELECT actor_uri FROM ap_followers WHERE slug = ?').all(slug)) circle.add(r.actor_uri); } catch { /* geen tabel */ }
-  const kept = [], out = { hidden: 0 };
-  for (const n of notes) {
-    // actorUriOf, niet n.attributedTo: sinds de byline ingesloten meegaat is
-    // dat een OBJECT en zou een kale vergelijking hier stil alles wegfilteren
-    // -- een ward met een lege thread en nergens een foutmelding.
-    if (circle.has(actorUriOf(n.attributedTo))) kept.push(n);
-    else out.hidden += 1;
-  }
-  out.notes = kept;
-  return out;
-}
-
-export async function resolveRemoteNote(url, opts = {}) {
-  if (!/^https?:\/\//i.test(String(url || ''))) return null;
-  // With `asSlug` the fetches are SIGNED as that local actor. An anonymous
-  // GET can only read public notes; a friends-only note (Shaer's default!)
-  // rightly refuses it, which made every reply to a friend's post fail while
-  // a reply to your own public post worked (Robins melding, 30-7). Signed,
-  // the other server sees WHO asks and serves what the friendship earns.
-  const get = (u) => (opts.asSlug ? signedGetJson(opts.asSlug, u) : fetchActor(u).catch(() => null));
-  const note = localNoteObject(url, opts.asSlug) || await get(url); // own DB first, then AP GET
-  if (!note || !note.id) return null;
-  const att = note.attributedTo;
-  const actorUri = actorUriOf(att);
-  if (!actorUri) return null;
-  const actor = localActorObject(actorUri) || await get(actorUri);
-  const ai = actorInfo(actor, actorUri);
-  // Is what we're replying to a post (or a comment) on one of OUR posts? If so,
-  // link our reply to that local post so it shows nested in the post thread.
-  const localTgt = findThreadTarget(note.id, (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''));
-  // Walk the WHOLE reply chain upward (comment → parent comment → … → root post)
-  // and collect every ancestor author's inbox, so each participant's server —
-  // including the original post's author — receives + threads our reply.
-  const threadInboxes = [];
-  const seenInbox = new Set();
-  let cursor = note.inReplyTo, guard = 0;
-  while (cursor && guard++ < 6) {
-    const url = typeof cursor === 'string' ? cursor : (cursor && cursor.id);
-    if (!url) break;
-    const pn = localNoteObject(url, opts.asSlug) || await get(url);
-    if (!pn) break;
-    const pa = actorUriOf(pn.attributedTo);
-    if (pa && pa !== actorUri) {
-      const paDoc = await get(pa);
-      const inbox = paDoc && ((paDoc.endpoints && paDoc.endpoints.sharedInbox) || paDoc.inbox);
-      if (inbox && !seenInbox.has(inbox)) { seenInbox.add(inbox); threadInboxes.push(inbox); }
-    }
-    cursor = pn.inReplyTo; // climb to the next ancestor
-  }
-  // For non-Note objects (PeerTube Video, Article, …) the meaningful label is `name` (the
-  // title); prepend it so the reply page shows what you're replying to (sanitize cleans it).
-  let rawHtml = String(note.content || '').replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
-  if (note.name && note.type && note.type !== 'Note') rawHtml = `<p><strong>${note.name}</strong></p>` + rawHtml;
-  const images = (Array.isArray(note.attachment) ? note.attachment : [])
-    .filter((a) => a && a.url && (!a.mediaType || /^image\//i.test(a.mediaType)))
-    .map((a) => safeUrl(a.url)).filter(Boolean);
-  // A Klonkt hosted-audio post strips its cover from `attachment` (so Mastodon
-  // shows the player card, not a loose image) and puts it in `image` instead.
-  // Same fallback as mediaFromNote() so a boosted music post keeps its cover.
-  if (!images.length && note.image) {
-    const im = Array.isArray(note.image) ? note.image[0] : note.image;
-    const iu = safeUrl(typeof im === 'string' ? im : (im && im.url));
-    if (iu) images.push(iu);
-  }
-  return {
-    object_uri: safeUrl(note.id) || note.id,
-    actor_uri: actorUri,
-    actor_url: ai.url,
-    actor_handle: ai.handle,
-    actor_name: ai.name,
-    actor_icon: ai.icon,
-    url: note.url || url,
-    content: HtmlSanitizerService.sanitize(rawHtml),       // full, sanitized
-    sensitive: !!note.sensitive,                            // remote CW → blur in the Cirkel
-    cw: contentWarning(note) || '',
-    images,
-    // Full typed media (incl. video/mp4) for the timeline cache. `images` above is
-    // image-only for the interact page preview; a boosted video-only post (Loops)
-    // lost its media entirely because upsertBoostedNote only saw `images`.
-    media: mediaFromNote(note),
-    threadInboxes,                                          // every ancestor author's inbox
-    localPostId: localTgt ? localTgt.post_id : '',          // our post this belongs to (if any)
-    poll: parsePoll(note),                                  // a Question → its options/counts (else null)
-    preview: HtmlSanitizerService.toPlainText(note.content || '').slice(0, 240),
-  };
-}
-
-// List a site's own outbound fediverse replies (for the manage/delete view).
-// The plain editable text of a stored reply (unwrap links → their text, <br> → newline)
-// so the manage view can prefill an edit box; the mention is re-added on save.
-function outboxEditableText(content) {
-  return String(content || '')
-    .replace(/<br\s*\/?>/gi, '\n')
-    .replace(/<a\b[^>]*>([\s\S]*?)<\/a>/gi, '$1')
-    .replace(/<[^>]+>/g, '')
-    .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')
-    .trim();
-}
-export function listOutbox(siteSlug) {
-  // post_slug reist mee sinds Berichten gesprekken toont: het is de sleutel
-  // waarop een verzonden antwoord bij de ontvangen antwoorden op dezelfde post
-  // gaat staan (zie threadKey). Zonder die kolom viel een uitwisseling uit
-  // elkaar in "Verzonden" en "Gesprekken".
-  return db.prepare('SELECT id, content, to_handle, to_actor, to_actors, post_slug, in_reply_to, attachments, language, created_at FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC')
-    .all(siteSlug).map((r) => { const c = stripLeadingMentions(r.content); return { ...r, content: c, editable: outboxEditableText(c) }; });
-}
-
-// Delete one of our outbound replies: send Delete(Tombstone) to recipients + remove it.
-export async function deliverOutboxDelete(site, outboxId) {
-  const row = iStmts().getO.get(outboxId);
-  if (!row || row.site_slug !== site.slug) return false;
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (base) {
-    const me = actorId(base, site.slug);
-    const nid = noteId(base, row.id);
-    const del = { '@context': AP_CONTEXT, id: `${nid}#delete-${Date.now()}-${rid()}`, type: 'Delete', actor: me, to: [PUBLIC], object: { id: nid, type: 'Tombstone' } };
-    const keys = getOrCreateKeys(site.slug);
-    const inboxes = new Set();
-    if (row.to_actor) { const a = await fetchActor(row.to_actor).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
-    for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
-    for (const inbox of [...inboxes].filter(Boolean)) {
-      try { const st = await deliver(inbox, del, `${me}#main-key`, keys.private_pem); if (st >= 200 && st < 300) continue; } catch { /* queue below */ }
-      enqueueDelivery(site.slug, inbox, del); // durable: a failed comment-delete now retries (was silently dropped)
-    }
-  }
-  db.prepare('DELETE FROM ap_outbox WHERE id = ?').run(outboxId);
-  return true;
-}
-
-// Edit one of our outbound replies: rewrite the stored content (mention re-added + #tags
-// re-linked) and send an Update(Note) so recipients refresh their cached copy.
-export async function deliverOutboxUpdate(site, outboxId, newText, opts = {}) {
-  const row = iStmts().getO.get(outboxId);
-  if (!row || row.site_slug !== site.slug) return false;
-  const text = String(newText || '').trim();
-  // Rich edit: same sanitize + enrichment pipeline as deliverReply.
-  const richClean = opts.html ? HtmlSanitizerService.sanitize(String(opts.html)) : '';
-  const rich = richClean && HtmlSanitizerService.toPlainText(richClean).trim() ? richClean : '';
-  if (!text && !rich) return false;
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base) return false;
-  const me = actorId(base, site.slug);
-  const toActor = row.to_actor ? await fetchActor(row.to_actor).catch(() => null) : null;
-  const toProfile = row.to_actor ? (actorInfo(toActor, row.to_actor).url || row.to_actor) : '';
-  const _h = row.to_handle || deriveHandle(row.to_actor);
-  const toHandle = _h && _h[0] === '@' ? _h : '@' + (_h || '');
-  // An edit must not drop co-mentions (u02): reuse the OLD content's leading
-  // mention anchors (the bar's kept list at send time) when present; only fall
-  // back to rebuilding the single to_actor mention for legacy rows.
-  const oldPrefix = (String(row.content || '')
-    .match(/^\s*(?:<p[^>]*>)?\s*((?:<a\b[^>]*class="u-url mention"[^>]*>\s*@[^<]+<\/a>[\s ]*)+)/i) || [])[1] || '';
-  const mention = oldPrefix || (row.to_actor
-    ? `<a href="${escHtml(toProfile)}" class="u-url mention" data-actor="${escHtml(row.to_actor)}">${escHtml(toHandle)}</a> ` : '');
-  let content;
-  let mres;
-  if (rich) {
-    mres = await resolveMentionsInText(base, rich);
-    const processed = linkUrls(linkHashtags(base, mres.html));
-    if (processed.startsWith('<p>')) content = processed.replace('<p>', `<p>${mention}`);
-    else if (/^<(blockquote|ul|ol|pre|h[1-6]|div|hr)\b/i.test(processed)) content = `<p>${mention}</p>${processed}`;
-    else content = `<p>${mention}${processed}</p>`;
-  } else {
-    mres = await resolveMentionsInText(base, escHtml(text).replace(/\r?\n/g, '<br>'));
-    content = `<p>${mention}${linkUrls(linkHashtags(base, mres.html))}</p>`;
-  }
-  // Language may be updated with the edit; attachments always survive untouched.
-  const newLang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(opts.language || '')) ? opts.language : null;
-  db.prepare('UPDATE ap_outbox SET content = ?, language = COALESCE(?, language) WHERE id = ?').run(content, newLang, outboxId);
-  const note = buildReplyNote(base, site, iStmts().getO.get(outboxId));
-  note.updated = new Date().toISOString();
-  const update = {
-    '@context': AP_CONTEXT,
-    id: `${note.id}#update-${Date.now()}-${rid()}`, type: 'Update', actor: me,
-    published: note.published, updated: note.updated, to: note.to, cc: note.cc, object: note,
-  };
-  const keys = getOrCreateKeys(site.slug);
-  const inboxes = new Set();
-  if (toActor) inboxes.add((toActor.endpoints && toActor.endpoints.sharedInbox) || toActor.inbox);
-  for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
-  mres.inboxes.forEach((i) => inboxes.add(i)); // people @mentioned inline in the edit
-  inboxes.delete(`${me}/inbox`); inboxes.delete(`${base}/ap/inbox`);
-  let delivered = 0;
-  for (const inbox of [...inboxes].filter(Boolean)) {
-    let ok = false;
-    try { const st = await deliver(inbox, update, `${me}#main-key`, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; }
-    if (ok) delivered++;
-    else enqueueDelivery(site.slug, inbox, update); // durable: retry the edit later (was silently dropped)
-  }
-  console.log('[AP] outreply edit', site.slug, 'delivered', delivered);
-  return { ok: true, content, delivered };
-}
-
-
-
-// Store the author's display-name emoji map (from actorInfo().emojis) on a
-// timeline row, so the byline can render a ":shortcode:" name. No-op when the
-// name has no custom emoji (the common case).
-function storeAuthorEmoji(id, slug, ai) {
-  if (!ai || !ai.emojis || !Object.keys(ai.emojis).length) return;
-  try { db.prepare('UPDATE ap_timeline SET author_emoji_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(ai.emojis), id, slug); } catch { /* ignore */ }
-}
-
-// A display-name emoji map (actorInfo().emojis) → JSON to store, or null.
-function emojiJsonOf(map) { return (map && Object.keys(map).length) ? JSON.stringify(map) : null; }
-
-
-
-// ── Self-heal: re-sync the fediverse cache (ap_timeline) after a DRASTIC update ──
-// Runs ONCE per SELFHEAL_VERSION bump — NOT on every boot. Re-fetches each cached
-// note and refreshes content + media (recovers covers/edits that were delivered
-// during a flux window, e.g. a fleet-wide update), and drops notes that are gone
-// (404/410). Bump SELFHEAL_VERSION only on a release that warrants a re-sync.
-const SELFHEAL_VERSION = 22; // v22: summary is pas een waarschuwing MET sensitive, en een artikel houdt zijn titel
-async function fetchNoteAP(url) {
-  try {
-    const r = await fetch(url, { headers: { Accept: 'application/activity+json' } });
-    if (r.status === 404 || r.status === 410) return 404;
-    if (r.ok) return await r.json();
-  } catch { /* unreachable */ }
-  return null;
-}
-function mediaFromNote(note) {
-  const atts = (Array.isArray(note.attachment) ? note.attachment : []).map((a) => {
-    const m = { url: safeUrl(a && a.url), type: (a && a.mediaType) || '' };
-    // A federated video may carry its poster as an AS2 icon (shaer-zowq).
-    const iconUrl = a && a.icon && safeUrl(typeof a.icon === 'string' ? a.icon : a.icon.url);
-    if (iconUrl && /^video\//i.test(m.type)) m.poster = iconUrl;
-    return m;
-  }).filter((m) => m.url);
-  if (!atts.some((m) => !m.type || /image/i.test(m.type)) && note.image) {
-    const im = Array.isArray(note.image) ? note.image[0] : note.image;
-    const iu = safeUrl(typeof im === 'string' ? im : (im && im.url));
-    if (iu) atts.push({ url: iu, type: (im && im.mediaType) || 'image/jpeg' });
-  }
-  return JSON.stringify(atts);
-}
-
-// FEP-044f, emit side. The mirror of extractQuoteUrl (ingest): when one of our
-// own posts quotes a fediverse object, say so in the shapes the network really
-// reads. `quote` is the FEP property; quoteUrl / _misskey_quote are the de-facto
-// ones Mastodon and Misskey look at, and the FEP-e232 `Link` in `tag` is the
-// third form. All three point at the same object, which is what every reader
-// expects. The quoted author goes in `cc`, because being quoted without being
-// told is exactly the rudeness this FEP is trying to design away.
-export function applyQuoteProps(note, quoteUri, quoteActor) {
-  if (!note || typeof quoteUri !== 'string' || !/^https?:\/\//i.test(quoteUri)) return note;
-  note.quote = quoteUri;
-  note.quoteUrl = quoteUri;
-  note['_misskey_quote'] = quoteUri;
-  note.tag = [...(note.tag || []), {
-    type: 'Link',
-    mediaType: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
-    href: quoteUri,
-    rel: ['https://misskey-hub.net/ns#_misskey_quote'],
-    name: quoteUri,
-  }];
-  if (typeof quoteActor === 'string' && /^https?:\/\//i.test(quoteActor)) {
-    note.cc = [...new Set([...(note.cc || []), quoteActor])];
-  }
-  return note;
-}
-
-// The first external (non-fediverse) link in a note, resolved to the same card
-// shape as a quote: THUMBNAIL ONLY, never the provider's iframe. An arbitrary
-// third-party frame inside a kid-safe app is a hole you cannot close again, so
-// the embed carries an image and a title and nothing executable.
-// Returns the JSON to store, or null when there is nothing worth showing.
-export async function resolveExternalEmbed(html) {
-  const first = firstExternalUrl(html);
-  if (!first) return null;
-  const io = EmbedResolver.liveIO({
-    safeFetch,
-    detectProvider: (u) => AudioEmbedService.detectProvider(u),
-    fetchActor,
-    actorInfo,
-  });
-  const card = await EmbedResolver.resolveEmbed(first, io).catch(() => null);
-  // 'ap' is handled by the quote path; a bare 'link' is not worth a card.
-  if (!card || card.kind === 'ap' || card.kind === 'link') return null;
-  const thumb = (card.media || []).find((m) => m && m.url);
-  if (!thumb && !card.title) return null;
-  // Title, provider and author name come from a third party. Store them as
-  // PLAIN TEXT (tags stripped, length-capped), so no renderer downstream has to
-  // be the one that remembers to escape. A card is a card, not an essay.
-  const plain = (v) => (v ? HtmlSanitizerService.toPlainText(String(v)).trim().slice(0, 200) : null);
-  return JSON.stringify({
-    url: card.url,
-    kind: card.kind,                       // 'provider' | 'oembed'
-    provider: plain(card.provider),
-    title: plain(card.title),
-    author: card.author ? { ...card.author, name: plain(card.author.name), handle: plain(card.author.handle) } : null,
-    media: thumb ? [thumb] : [],           // thumbnail only, no html/iframe
-  });
-}
-
-/**
- * Does our own post link to a fediverse object? Returns { uri, actor } when the
- * first external link resolves to a quotable AP object, else null. Runs once at
- * publish time; the answer is stored on the post.
- */
-export async function resolveOwnQuote(html) {
-  const first = firstExternalUrl(html);
-  if (!first) return null;
-  const io = EmbedResolver.liveIO({ safeFetch, detectProvider: () => null, fetchActor, actorInfo });
-  const card = await EmbedResolver.resolveEmbed(first, io).catch(() => null);
-  if (!card || card.kind !== 'ap' || !card.id) return null;
-  return { uri: card.id, actor: card.attributedTo || null };
-}
-
-/**
- * De composer-preview (shaer-k3f): één URL langs exact dezelfde pijplijn als
- * publiceren, zodat wat de preview toont ook is wat de post krijgt. Twee
- * uitkomsten, hoogstens een gevuld: een AP-object wordt een quote-snapshot,
- * een externe link probeert een kaart. Beide als JSON-string, dezelfde vorm
- * als de kolommen -- de route serveert ze door timelineQuote/timelineEmbed en
- * de gate, net als de tijdlijn.
- */
-export async function previewCard(url) {
-  if (!/^https?:\/\//i.test(String(url || ''))) return {};
-  const html = `<a href="${String(url).replace(/"/g, '&quot;')}">x</a>`;
-  const q = await resolveOwnQuote(html);
-  if (q && q.uri) {
-    const quoteJson = await resolveQuoteByUri(q.uri).catch(() => null);
-    if (quoteJson) return { quoteJson };
-  } else {
-    const embedJson = await resolveExternalEmbed(html).catch(() => null);
-    if (embedJson) return { embedJson };
-  }
-  return {};
-}
-
-/** The first http(s) link in sanitized note HTML that is not a mention/hashtag. */
-export function firstExternalUrl(html) {
-  if (!html || typeof html !== 'string') return null;
-  for (const m of html.matchAll(/<a\b[^>]*href=["']([^"']+)["'][^>]*>/gi)) {
-    const tag = m[0];
-    if (/\b(mention|hashtag|u-url)\b/i.test(tag) && /mention|hashtag/i.test(tag)) continue;
-    const href = m[1];
-    if (/^https?:\/\//i.test(href)) return href;
-  }
-  return null;
-}
-
-/** The stored external-embed card, for the C2S read. */
-// ── Standaardvormen in plaats van eigen dialect (shaer-nmw) ───────
-//
-// Robins waarschuwing: geen Klonkt/Shaer-dialect schrijven waar AS2 of een FEP
-// het al regelt. Vier eigen properties hadden een standaard naast zich staan,
-// en deze helpers zijn die standaard -- een definitie per vorm, zodat de tien
-// plekken die ze emitten niet elk hun eigen variant krijgen.
-//
-// De oude shaer:-velden blijven er voorlopig NAAST staan. Een app in het veld
-// leest ze nog, en een leeg scherm is een duurdere fout dan een dubbel veld;
-// ze gaan eruit als de clients om zijn (tweede helft van shaer-nmw).
-
-/** FEP-9098 Emoji-tags uit een {shortcode: url}-kaart. */
-function emojiTagsFromMap(emojis) {
-  const uit = Object.entries(emojis || {})
-    .filter(([naam, url]) => naam && url)
-    .map(([naam, url]) => ({ type: 'Emoji', name: naam, icon: { type: 'Image', url } }));
-  return uit.length ? uit : undefined;
-}
-
-/**
- * Een actor als INGESLOTEN OBJECT voor `attributedTo` / `actor`.
- *
- * AS2 staat toe dat attributedTo een object is in plaats van een URI, en dan
- * heeft ELKE client er wat aan -- niet alleen de onze, die er shaer:author
- * naast kreeg. `preferredUsername` is de lokale naam; een lezer leidt de handle
- * af uit die naam plus de host van de id, precies zoals wij serverkant ook
- * doen. Weten we niets van de persoon, dan blijft het de kale URI: een leeg
- * object zou beweren dat we hem kennen.
- */
-export function actorObject(uri, info) {
-  if (!uri) return undefined;
-  const iets = info && (info.name || info.handle || info.icon || info.url);
-  if (!iets) return uri;
-  const o = { id: uri, type: 'Person' };
-  if (info.name) o.name = info.name;
-  const lokaal = String(info.handle || '').replace(/^@/, '').split('@')[0];
-  if (lokaal) o.preferredUsername = lokaal;
-  if (info.icon) o.icon = { type: 'Image', url: info.icon };
-  if (info.url) o.url = info.url;
-  const tags = emojiTagsFromMap(info.emojis);
-  if (tags) o.tag = tags;
-  return o;
-}
-
-/**
- * De linkkaart als AS2 `preview` (core: "identifies an entity that provides a
- * preview of this object"). Een Page met url, name en image IS een kaart; daar
- * hoefde shaer:embed nooit voor te bestaan.
- *
- * Wat WEL van ons blijft is de spelerpagina: dat die alleen meegaat als de
- * guardians de poort openden is FEP-633c-gedrag en heeft geen AS2-tegenhanger.
- */
-export function previewObject(embedJson, { playback = false } = {}) {
-  const e = timelineEmbed(embedJson, { playback });
-  if (!e) return undefined;
-  const thumb = (e.media || []).find((m) => m && m.url);
-  const p = { type: 'Page', url: e.url };
-  if (e.title) p.name = e.title;
-  if (thumb) p.image = { type: 'Image', url: thumb.url };
-  if (e.author && (e.author.name || e.author.handle)) {
-    p.attributedTo = { type: 'Person', name: e.author.name || e.author.handle };
-  }
-  if (e['shaer:playerUrl']) p['shaer:playerUrl'] = e['shaer:playerUrl'];
-  if (e['shaer:playable']) p['shaer:playable'] = e['shaer:playable'];
-  return p;
-}
-
-/**
- * De geciteerde post als OBJECT in `quote` (FEP-044f staat toe dat quote het
- * object zelf is, niet alleen een URI). De opgeslagen momentopname wordt hier
- * een echte Note, met de auteur als ingesloten actor -- dus geen tweede eigen
- * property voor iets dat de FEP al kan.
- */
-export function quoteObject(quoteJson) {
-  const q = timelineQuote(quoteJson);
-  if (!q) return undefined;
-  const note = { type: 'Note', id: q.url, url: q.url };
-  if (q.content) note.content = q.content;
-  if (q.published) note.published = q.published;
-  if (q.author) {
-    note.attributedTo = actorObject(q.author.url || q.url, {
-      name: q.author.name, handle: q.author.handle, icon: q.author.icon,
-    });
-  }
-  const media = (q.media || []).filter((m) => m && m.url)
-    .map((m) => ({ type: 'Document', mediaType: m.type || undefined, url: m.url }));
-  if (media.length) note.attachment = media;
-  const tags = emojiTagsFromMap(q.emojis);
-  if (tags) note.tag = tags;
-  return note;
-}
-
-export function timelineEmbed(embedJson, { playback = false } = {}) {
-  try {
-    const e = embedJson ? JSON.parse(embedJson) : null;
-    if (!e || typeof e !== 'object' || !e.url) return undefined;
-    // The player URL is served ONLY when the playback gate is open (FEP-633c
-    // 5.6). Deciding it here keeps the provider knowledge in one place: the
-    // client never needs a list of hosts, it just plays what it is handed.
-    // Privacy-enhanced variants only: nocookie for YouTube, the instance's own
-    // player for PeerTube. Without one the card stays a thumbnail.
-    const player = playback ? playerUrlFor(e.url) : null;
-    if (player) return { ...e, 'shaer:playerUrl': player };
-    // The gate is shut and there IS something behind it. Saying so costs
-    // nothing (the card already shows a video thumbnail) and saves the child
-    // from tapping a card that will never answer: the app can explain instead
-    // of doing nothing. It stays a statement of fact, never a way in.
-    return playerUrlFor(e.url) ? { ...e, 'shaer:playable': true } : e;
-  } catch { return undefined; }
-}
-
-/** The embeddable player for a URL, or null when we will not frame it. */
-export function playerUrlFor(url) {
-  if (typeof url !== 'string') return null;
-  let p = null;
-  try { p = AudioEmbedService.detectProvider(url); } catch { p = null; }
-  if (p && p.provider === 'youtube' && p.id) return `https://www.youtube-nocookie.com/embed/${p.id}?rel=0&modestbranding=1&playsinline=1`;
-  if (p && p.provider === 'vimeo' && p.id) return `https://player.vimeo.com/video/${p.id}`;
-  // PeerTube is decentralised, so it is matched by its watch-URL shape rather
-  // than a provider list. Host chars are validated before it is inlined.
-  const pt = url.match(/^https?:\/\/([\w.-]+(?::\d+)?)\/(?:w|videos\/watch)\/([\w-]{6,})/i);
-  if (pt) return `https://${pt[1]}/videos/embed/${pt[2]}`;
-  return null;
-}
-
-// FEP-044f embedded quote card: resolve the quoted post to a compact, sanitised
-// snapshot { url, author{name,handle,icon}, content, published, media } so the
-// client can render it as a nested card instead of a bare link. Best-effort and
-// SSRF-safe (apGetJson): returns null on any failure, and the client falls back
-// to the object-link chip. The content goes through the same sanitiser as every
-// other note, so the kid-safe guarantees hold.
-async function resolveQuote(note) {
-  const url = quoteHrefOf(note);
-  if (!url) return null;
-  return resolveQuoteByUri(url);
-}
-
-/** Hetzelfde snapshot, maar vanaf een kale URI: eigen posts en de
- *  composer-preview (shaer-k3f) kennen alleen de link, niet de tag-vorm. */
-async function resolveQuoteByUri(url) {
-  const q = await apGetJson(url);
-  if (!q || typeof q !== 'object') return null;
-  const authorUri = typeof q.attributedTo === 'string' ? q.attributedTo
-    : (q.attributedTo && typeof q.attributedTo.id === 'string' ? q.attributedTo.id : null);
-  const ai = authorUri ? actorInfo(await fetchActor(authorUri), authorUri) : null;
-  // The quoted post's own FEP-9098 emojis, so :shortcode: renders in the card.
-  const emojis = {};
-  try {
-    for (const e of JSON.parse(extractEmojiTags(q.tag) || '[]')) {
-      const u = e.icon && (e.icon.url || (Array.isArray(e.icon) && e.icon[0] && e.icon[0].url));
-      if (typeof e.name === 'string' && u) emojis[e.name] = u;
-    }
-  } catch { /* ignore */ }
-  let media = []; try { media = JSON.parse(mediaFromNote(q)); } catch { /* ignore */ }
-  const snapshot = {
-    url: safeUrl(q.url || q.id || url) || url,
-    author: ai ? { name: ai.name, handle: ai.handle, icon: ai.icon } : null,
-    content: HtmlSanitizerService.sanitize(q.content || ''),
-    published: q.published || null,
-    media,
-    emojis: Object.keys(emojis).length ? emojis : undefined,
-  };
-  return JSON.stringify(snapshot);
-}
-
-/**
- * The card under a post: a fediverse quote (FEP-044f) when the note has one,
- * otherwise an external link preview. Both render as the SAME card, so only one
- * of the two is ever stored. Returns {column, json} or null.
- *
- * Both halves reach out over the network, which is why every caller runs this
- * out of band: an inbox answer must never wait on a third party.
- */
-async function resolveCard(o) {
-  if (quoteHrefOf(o)) {
-    const qj = await resolveQuote(o);
-    return qj ? { column: 'quote_json', json: qj } : null;
-  }
-  const ej = await resolveExternalEmbed(o && o.content);
-  return ej ? { column: 'embed_json', json: ej } : null;
-}
-
-// AP-native catch-up: pull an actor's standard `outbox` collection and merge their recent
-// top-level posts into the timeline for `slug`. Push (Create delivery) cannot backfill
-// history-from-before-you-followed or a delivery that was missed while you were down;
-// reading the outbox is the spec-conform way to catch up. PULL ONLY — sends nothing.
-export async function backfillFromOutbox(slug, actorUri, limit = 20) {
-  try {
-    if (!slug || !actorUri) return 0;
-    const actor = await fetchActor(actorUri);
-    if (!actor || !actor.outbox) return 0;
-    // Signed as the follower (30-7): the serving side recognises an accepted
-    // friend and hands the friends-only history along; an anonymous GET only
-    // ever sees the public set. A server that ignores the signature behaves
-    // exactly as before.
-    let page = await signedGetJson(slug, typeof actor.outbox === 'string' ? actor.outbox : actor.outbox.id);
-    let items = (page && (page.orderedItems || page.items)) || [];
-    if (!items.length && page && page.first) {
-      page = await signedGetJson(slug, typeof page.first === 'string' ? page.first : page.first.id);
-      items = (page && (page.orderedItems || page.items)) || [];
-    }
-    if (!Array.isArray(items) || !items.length) return 0;
-    const ai = actorInfo(actor, actorUri);
-    let added = 0;
-    for (const it of items.slice(0, limit)) {
-      // Each item is usually a Create wrapping a Note, or sometimes the Note itself.
-      const o = (it && typeof it.object === 'object' && it.object) ? it.object : it;
-      if (!o || !o.id) continue;
-      if (o.type && o.type !== 'Note' && o.type !== 'Article' && o.type !== 'Question') continue; // skip boosts/other
-      if (o.inReplyTo) continue;                                          // top-level only
-      const auth = actorUriOf(o.attributedTo);
-      if (auth && auth !== actorUri) continue;                            // their OWN posts only
-      const html = HtmlSanitizerService.sanitize(o.content || '');
-      const poll = parsePoll(o); // a Question (poll) → carry its options/counts on backfill too
-      try {
-        const r = tlStmts().ins.run(o.id, slug, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.url || null, o.published || null, mediaFromNote(o), o.sensitive ? 1 : 0, contentWarning(o));
-        if (r && r.changes > 0) added++;
-        // FEP-9098: keep custom-emoji tags from backfilled posts too.
-        { const ej = extractEmojiTags(o.tag); if (ej) { try { db.prepare('UPDATE ap_timeline SET emoji_json = ? WHERE id = ? AND slug = ?').run(ej, o.id, slug); } catch { /* ignore */ } } }
-        storeAuthorEmoji(o.id, slug, ai);   // custom-emoji display name for the byline
-        // FEP-e232 + FEP-044f: keep object-link/quote tags from backfilled posts too.
-        { const lj = extractLinkJson(o); if (lj) { try { db.prepare('UPDATE ap_timeline SET link_json = ? WHERE id = ? AND slug = ?').run(lj, o.id, slug); } catch { /* ignore */ } } }
-        // FEP-044f: resolve the embedded quote card for backfilled posts too.
-        if (quoteHrefOf(o)) { const qj = await resolveQuote(o); if (qj) { try { db.prepare('UPDATE ap_timeline SET quote_json = ? WHERE id = ? AND slug = ?').run(qj, o.id, slug); } catch { /* ignore */ } } }
-        // Set poll_json if this is a poll and we don't already have it (COALESCE preserves a vote).
-        if (poll) { try { db.prepare('UPDATE ap_timeline SET poll_json = COALESCE(poll_json, ?) WHERE id = ? AND slug = ?').run(JSON.stringify(poll), o.id, slug); } catch { /* ignore */ } }
-      } catch { /* ignore */ }
-    }
-    if (added) console.log('[AP] outbox backfill', actorUri, '→', slug, '+' + added);
-    return added;
-  } catch { return 0; }
-}
-
-// ── Remote thread crawl (fill the gaps in a local post's conversation) ────────────
-// Most replies reach us by delivery, but replies-to-replies that live on other servers and
-// aren't addressed to us are missed. This pulls the AS2 `replies` collections of the replies
-// we DO have, caching any newly-found ones in ap_interactions.
-//
-// Matches Mastodon's behaviour: ONE level per crawl (like its FetchRepliesService), not a deep
-// recursive walk. Deeper levels fill in incrementally across crawls — once a fetched reply is
-// cached it becomes a seed itself, so its own replies are pulled on a later view (Mastodon's
-// per-status cascade). Bounded + polite (serial), PULL only, and stale-while-revalidate: it
-// never runs in a page request — the view renders from cache; a stale post kicks off a
-// background refresh for the NEXT view.
-const THREAD_TTL_MS = 15 * 60 * 1000;   // don't re-crawl a post more than ~4×/hour
-const THREAD_MAX_DEPTH = 1;             // one hop per crawl (like Mastodon); deeper fills in over crawls
-const THREAD_MAX_FETCHES = 30;          // hard cap on remote GETs per crawl (be a good peer)
-const _crawlingThreads = new Set();     // per-post in-flight lock (no stampede across views)
-
-function threadCrawlTs(postId) {
-  try { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('thread_crawl:' + postId); return r ? (Number(r.value) || 0) : 0; }
-  catch { return 0; }
-}
-function setThreadCrawlTs(postId, ts) {
-  try { db.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run('thread_crawl:' + postId, String(ts)); }
-  catch { /* ignore */ }
-}
-
-// Read a note's `replies` (string ref / Collection with `first` / paged CollectionPages) →
-// child note URIs. Every remote GET goes through `budget` so the whole crawl stays capped.
-async function collectReplyItems(repliesRef, maxPages, budget) {
-  const uris = [];
-  let node = typeof repliesRef === 'string' ? await budget.get(repliesRef) : repliesRef;
-  if (node && node.first) node = typeof node.first === 'string' ? await budget.get(node.first) : node.first;
-  let pages = 0;
-  while (node && pages++ < maxPages) {
-    for (const it of (node.items || node.orderedItems || [])) {
-      const u = typeof it === 'string' ? it : (it && it.id);
-      if (u && /^https?:\/\//i.test(u)) uris.push(u);
-    }
-    if (!node.next) break;
-    node = typeof node.next === 'string' ? await budget.get(node.next) : node.next;
-  }
-  return uris;
-}
-
-async function crawlThread(postId) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base) return;
-  // Seed frontier = the remote reply note URIs we already have; also the dedup set.
-  let known;
-  try { known = new Set(db.prepare("SELECT object_uri FROM ap_interactions WHERE post_id = ? AND kind = 'reply' AND object_uri != ''").all(postId).map((r) => r.object_uri)); }
-  catch { return; }
-  const seeds = [...known].filter((u) => /^https?:\/\//i.test(u));
-  if (!seeds.length) return; // nothing remote to expand
-  // Owner-removed replies (tombstones) join the dedup set AFTER seeding, so the
-  // crawler never re-adds them via thread-filling (they're gone from the seeds
-  // already because rejectInteraction deleted their ap_interactions row).
-  try { for (const r of db.prepare('SELECT object_uri FROM ap_rejected_objects WHERE post_id = ?').all(postId)) known.add(r.object_uri); }
-  catch { /* table always exists after boot migration */ }
-
-  let fetches = 0;
-  const budget = { get: async (u) => { if (fetches >= THREAD_MAX_FETCHES) return null; fetches++; return apGetJson(u); } };
-  const visited = new Set(); // notes whose replies collection we've already expanded
-  let frontier = seeds.slice();
-  let added = 0;
-
-  for (let depth = 0; depth < THREAD_MAX_DEPTH && frontier.length && fetches < THREAD_MAX_FETCHES; depth++) {
-    const nextFrontier = [];
-    for (const noteUri of frontier) {
-      if (visited.has(noteUri) || fetches >= THREAD_MAX_FETCHES) continue;
-      visited.add(noteUri);
-      const note = await budget.get(noteUri);
-      if (!note || !note.replies) continue;
-      const childUris = await collectReplyItems(note.replies, 2, budget);
-      for (const cu of childUris) {
-        if (known.has(cu) || fetches >= THREAD_MAX_FETCHES) continue;
-        known.add(cu);
-        const child = await budget.get(cu);
-        if (!child || !child.id || (child.type !== 'Note' && child.type !== 'Article')) continue;
-        if (isRejectedObject(child.id)) continue; // note id can differ from the collection URI (redirects)
-        const actorUri = actorUriOf(child.attributedTo);
-        if (!actorUri || isBlockedAny(actorUri)) continue; // skip blocked authors
-        const actor = await budget.get(actorUri); // may be null if budget spent → fallback handle
-        const ai = actorInfo(actor, actorUri);
-        const html = HtmlSanitizerService.sanitize(child.content || '');
-        // The child replies to `note` by construction (it's in note's replies collection).
-        try { iStmts().ins.run('reply', postId, child.id, actorUri, ai.name, ai.handle, ai.url, ai.icon, html, child.published || null, note.id || noteUri, noteVisibility(child), extractEmojiTags(child.tag), emojiJsonOf(ai.emojis)); added++; } catch { /* ignore */ }
-        nextFrontier.push(child.id); // expand this reply's own replies next depth
-      }
-    }
-    frontier = nextFrontier;
-  }
-  if (added) console.log('[AP] thread crawl', postId, '+' + added, 'remote replies (' + fetches + ' fetches)');
-}
-
-// Stale-while-revalidate entry point: call from the post view. Renders nothing, blocks nothing —
-// fires a background crawl only if this post hasn't been crawled within the TTL.
-export function maybeCrawlThread(postId) {
-  if (!postId || _crawlingThreads.has(postId)) return;
-  if (Date.now() - threadCrawlTs(postId) < THREAD_TTL_MS) return;
-  _crawlingThreads.add(postId);
-  setThreadCrawlTs(postId, Date.now()); // optimistic mark so concurrent/next views don't re-fire
-  crawlThread(postId).catch((e) => console.warn('[AP] thread crawl failed:', e && e.message)).finally(() => _crawlingThreads.delete(postId));
-}
-
-let _selfHealing = false;
-export async function selfHealTimeline() {
-  if (_selfHealing) return; _selfHealing = true;
-  try {
-    let cur = 0;
-    try { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('selfheal_version'); cur = r ? (parseInt(r.value, 10) || 0) : 0; } catch { return; }
-    if (cur >= SELFHEAL_VERSION) return; // already healed for this version — skip on normal boots
-    // v21: direct notes used to land in the timeline as if they were posts, so a
-    // ward's 🛟 help request showed up in the guardian's Krant. The insert now
-    // refuses them; drop the ones already cached. Scoped to the two kinds we can
-    // still recognise afterwards (help request, wave) — a plain public mention
-    // from someone you follow IS a timeline post and must stay.
-    try {
-      const r = db.prepare(`DELETE FROM ap_timeline WHERE EXISTS (
-        SELECT 1 FROM ap_mentions m
-         WHERE m.object_uri = ap_timeline.id AND m.slug = ap_timeline.slug
-           AND (m.help_request = 1 OR m.wave = 1))`).run();
-      if (r.changes) console.log(`[AP] self-heal v21: ${r.changes} direct note(s) removed from the timeline`);
-    } catch { /* table may predate the columns */ }
-    let rows = [];
-    try { rows = db.prepare('SELECT id, slug, content, media_json, nsfw, cw, url, emoji_json, link_json, quote_json, author_uri, author_name, author_emoji_json, reblog_name, reblog_handle, reblog_emoji_json, embed_json FROM ap_timeline ORDER BY rowid DESC LIMIT 200').all(); } catch { /* no table */ }
-    let healed = 0, failed = 0;
-    for (const r of rows) {
-      // Link previews first, and deliberately BEFORE the note re-fetch. A
-      // preview is resolved from the content we already hold, so hanging it
-      // behind a remote fetch meant one unreachable origin skipped the whole
-      // row (`continue` below) and the card never appeared. It needs nothing
-      // from the origin, so it must not depend on it.
-      if (!r.quote_json && !r.embed_json) {
-        try {
-          const ej = await resolveExternalEmbed(r.content);
-          if (ej) db.prepare('UPDATE ap_timeline SET embed_json = ? WHERE id = ?').run(ej, r.id);
-        } catch { /* best-effort, never blocks the heal */ }
-      }
-      try {
-        const note = await fetchNoteAP(r.id);
-        if (note === 404) { db.prepare('DELETE FROM ap_timeline WHERE id = ?').run(r.id); healed++; continue; }
-        if (!note || typeof note !== 'object') { failed++; continue; } // origin unreachable right now
-        // Door DEZELFDE bouwer als de innamekant (v22). Hij bouwde de inhoud
-        // hier zelf op, en daardoor miste een gerepareerde rij precies wat de
-        // inname wel doet -- de titel van een artikel bijvoorbeeld. Een
-        // zelfherstel dat een andere vorm oplevert dan de inname repareert naar
-        // een derde toestand.
-        const velden = timelineFields(note);
-        const html = velden.html;
-        const media = velden.atts.length ? JSON.stringify(velden.atts) : mediaFromNote(note);
-        const nsfw = note.sensitive ? 1 : 0;   // re-sync NSFW/sensitive + CW onto already-cached posts
-        const cw = contentWarning(note);
-        const url = note.url || null;          // re-sync the human url (catches a remote slug rename)
-        const emoji = extractEmojiTags(note.tag);   // FEP-9098: re-capture custom-emoji tags (v8)
-        const link = extractLinkJson(note);   // FEP-e232 + FEP-044f: re-capture object-link/quote tags (v9)
-        // FEP-044f: resolve the embedded quote card (v11). COALESCE-style: keep a
-        // cached snapshot if the quoted post is momentarily unreachable now.
-        const quote = quoteHrefOf(note) ? (await resolveQuote(note)) || r.quote_json || null : null;
-        if ((html && html !== r.content) || media !== (r.media_json || '[]') || nsfw !== (r.nsfw || 0) || (cw || '') !== (r.cw || '') || (url && url !== r.url) || (emoji || '') !== (r.emoji_json || '') || (link || '') !== (r.link_json || '') || (quote || '') !== (r.quote_json || '')) {
-          db.prepare('UPDATE ap_timeline SET content = ?, media_json = ?, nsfw = ?, cw = ?, url = COALESCE(?, url), emoji_json = ?, link_json = ?, quote_json = ? WHERE id = ?').run(html || r.content, media, nsfw, cw, url, emoji, link, quote, r.id);
-          healed++;
-        }
-        // v13: a custom-emoji display name needs the author's emoji map. Fetch
-        // the actor once, only for rows whose name has a shortcode and no map yet.
-        if (/:[A-Za-z0-9_+-]+:/.test(r.author_name || '') && !r.author_emoji_json && r.author_uri) {
-          const ai = actorInfo(await fetchActor(r.author_uri), r.author_uri);
-          if (ai.emojis) { try { db.prepare('UPDATE ap_timeline SET author_emoji_json = ? WHERE id = ?').run(JSON.stringify(ai.emojis), r.id); } catch { /* ignore */ } }
-        }
-        // v14: same for the booster's display name ("X boosted"). The row stores
-        // no booster URI, so resolve it from the handle via webfinger. Scoped to
-        // this exact row (slug) since a note can be boosted by different people.
-        if (/:[A-Za-z0-9_+-]+:/.test(r.reblog_name || '') && !r.reblog_emoji_json && r.reblog_handle) {
-          const bUri = await webfingerResolve(r.reblog_handle);
-          const em = bUri ? actorNameEmojis(await fetchActor(bUri)) : undefined;
-          if (em) { try { db.prepare('UPDATE ap_timeline SET reblog_emoji_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(em), r.id, r.slug); } catch { /* ignore */ } }
-        }
-      } catch { failed++; /* per-note best-effort */ }
-    }
-    // Only mark this version DONE after a clean pass. Some origins are briefly
-    // offline exactly when we heal (phone-hosted instances!): skipping them and
-    // consuming the version would leave those rows stale forever. Instead retry
-    // on the next boots, giving up after a few attempts (permanently-dead
-    // origins answer 404/410 and are deleted above, so they don't loop).
-    const setSetting = (k, v) => { try { db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(k, String(v)); } catch { /* ignore */ } };
-    let attempts = 0;
-    try { const a = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('selfheal_attempts'); attempts = a ? (parseInt(a.value, 10) || 0) : 0; } catch { /* ignore */ }
-    if (failed === 0 || attempts >= 4) {
-      setSetting('selfheal_version', SELFHEAL_VERSION);
-      setSetting('selfheal_attempts', 0);
-    } else {
-      setSetting('selfheal_attempts', attempts + 1);
-    }
-    if (rows.length) console.log(`[AP] self-heal v${SELFHEAL_VERSION}: ${healed}/${rows.length} timeline notes${failed ? ` (${failed} unreachable — will retry next boot)` : ''}`);
-  } catch { /* never block boot */ } finally { _selfHealing = false; }
-}
-
-// Follow a fediverse account by @handle (WebFinger → actor → signed Follow).
-
-/**
- * FEP-7628 (DRAFT status — the shape is Mastodon's since 2019, but the FEP can
- * still change): an account our sites follow says it moved to a new home.
- *
- * Validity has two independent legs, and both must hold:
- *  1. The SIGNER is a party to the move: the old actor announcing its own move
- *     (push mode) or the new actor doing it (pull mode). A third party
- *     narrating someone else's move is refused — without this, any signed
- *     stranger could re-point our follows.
- *  2. The NEW actor claims the old identity in its `alsoKnownAs`. That is the
- *     cross-side proof: the mover controls both ends. Without it, whoever
- *     holds ONE end could hijack the other end's followers.
- *
- * Effect: every local site following the old actor unfollows it and follows
- * the new one, keeping its auto-boost choice. Deliberately NOT retargeted:
- * guardianship relations (FEP-633c) — a guardian is a security anchor, not a
- * feed subscription, and moving one is shaer-tge's gated decision, not a
- * side effect of an inbox event. We only log when a move touches one.
- *
- * Deps are injectable for tests (no network in node:test).
- */
-export async function handleMoveInbox(act, { verifiedActor = null, fetchActorFn = null, followFn = null, unfollowFn = null } = {}) {
-  const oldUri = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
-  const newUri = typeof act.target === 'string' ? act.target : (act.target && act.target.id);
-  if (!oldUri || !newUri || oldUri === newUri) return 400;
-  if (!verifiedActor || (verifiedActor !== oldUri && verifiedActor !== newUri)) {
-    console.warn('[AP] Move refused: signer is not a party to the move', verifiedActor || '(unsigned)', oldUri, '→', newUri);
-    return 401;
-  }
-  // Nobody here follows the old actor → nothing to move. This also makes
-  // redelivery idempotent: after the first swap the rows are gone.
-  let rows = [];
-  try { rows = db.prepare('SELECT * FROM ap_following WHERE actor_uri = ?').all(oldUri); } catch { /* fresh init */ }
-  if (!rows.length) return 202;
-  // A blocked destination is declined outright: the old follow stays (it goes
-  // stale on its own), and we will not open a door to a blocked house.
-  if (isBlockedAny(newUri)) { console.log('[AP] Move dropped: target is blocked', newUri); return 202; }
-  const target = await (fetchActorFn || fetchActor)(newUri);
-  const aka = [].concat((target && target.alsoKnownAs) || [])
-    .map((a) => (typeof a === 'string' ? a : (a && a.id))).filter(Boolean);
-  if (!target || !target.id || !aka.includes(oldUri)) {
-    console.warn('[AP] Move refused: target does not claim the old actor in alsoKnownAs', oldUri, '→', newUri);
-    return 202; // decline to act; no 4xx, the sender may be a well-meaning retrying server
-  }
-  // EERST de guardianship, DAARNA pas de follows. Die volgorde is geen netheid
-  // maar de hele werking, en hij is met bloed geschreven: bij Robins verhuizing
-  // op 13-8 stond het andersom en het log liet precies zien wat er dan gebeurt.
-  //
-  //   [AP] outgoing Follow beta → .../robo (gated, awaiting guardians)
-  //
-  // Beta is zelf een ward. Zijn UITGAANDE follow naar de verhuisde guardian werd
-  // gepoort (§5.3), want op dat moment stond het nieuwe adres nog niet in zijn
-  // guardian-lijst: de code hieronder had de relatie nog niet bijgewerkt. En de
-  // INKOMENDE kant heeft hetzelfde probleem, want de ward gate't een Follow van
-  // een onbekende. Dus beide richtingen bleven hangen op goedkeuring die niemand
-  // hoefde te geven, omdat het om een guardian ging die er al was.
-  //
-  // Met de relatie eerst is de verhuisde actor al een erkende guardian als de
-  // follows langskomen, en gaat de auto-acceptatie gewoon door.
-  //
-  // Een Move is een Move: de guardian is dezelfde guardian, het kind is hetzelfde
-  // kind, alleen het adres is nieuw. Zelfde bescherming als de re-follow: alleen
-  // na een geverifieerde Move, en niet naar een geblokkeerde bestemming (daar
-  // zijn we hierboven al uitgestapt). De twee harde randen van shaer-tge staan
-  // hier LOS van: weigeren te verhuizen naar een instance die shaer:guardians
-  // niet kan dragen is een controle aan de UITGAANDE kant, en het
-  // terugkeren-zonder-set is een alsoKnownAs-kwestie.
-  try {
-    const g = db.prepare('SELECT slug, role FROM ap_guardianships WHERE other_uri = ? AND status = ?').all(oldUri, 'accepted');
-    if (g.length) {
-      const r = db.prepare('UPDATE ap_guardianships SET other_uri = ? WHERE other_uri = ? AND status = ?').run(newUri, oldUri, 'accepted');
-      console.log('[AP] guardianship moved:', oldUri, '→', newUri, `(${r.changes}x)`, g.map((x) => `${x.role}:${x.slug}`).join(', '));
-    }
-  } catch (e) { console.warn('[AP] guardianship move failed:', e && e.message); }
-
-  for (const row of rows) {
-    const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(row.slug);
-    if (!site) continue;
-    try {
-      await (unfollowFn || unfollowActor)(site, oldUri);
-      const already = fwStmts().one.get(row.slug, newUri);
-      if (!already) await (followFn || followActor)(site, newUri, !!row.auto_boost);
-      console.log('[AP] follow moved', row.slug, ':', oldUri, '→', newUri);
-    } catch (e) {
-      console.warn('[AP] move re-follow failed for', row.slug, e && e.message);
-    }
-  }
-  return 202;
-}
-
-/**
- * Slice 2 van shaer-0j2 (FEP-7628, DRAFT): de UITGAANDE helft — deze Klonkt
- * is het oude huis en kondigt het vertrek aan. Twee eisen voordat er iets
- * de deur uit gaat:
- *  1. Geen guardians: een warded account verhuizen zonder de guardianship
- *     te hertargeten zou het vangnet van het kind stil breken; dat is
- *     shaer-tge's gated beslissing, dus tot die er is weigert een bewaakt
- *     account de verhuizing.
- *  2. De NIEUWE actor claimt ons in alsoKnownAs — dezelfde back-reference
- *     die elke ontvangende server (onze eigen slice 1 incluis) eist. Zonder
- *     die claim is de Move overal dood bij aankomst.
- * De Move gaat duurzaam naar elke volger-inbox; hun servers doen de
- * re-follow. `moved_to` wordt hier vastgelegd; het SERVEREN ervan op de
- * actor (en het beleid van de oude site) is slice 3.
- * Deps injecteerbaar voor tests (geen netwerk in node:test).
- */
-export async function moveAccount(site, targetRaw, { fetchActorFn = null, deliverFn = null } = {}) {
-  // Al verhuisd? Dan eerst het slot eraf (moved_to leegmaken). Anders stapel je
-  // wegwijzers op elkaar en weet niemand meer waar de keten eindigt.
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !site || !site.slug) return { error: 'config' };
-  if (movedLock(site).locked) return { error: 'already_moved', movedTo: movedLock(site).movedTo };
-  try {
-    // Was een harde weigering voor elk bewaakt account (shaer-tge); sinds 8-8
-    // een GATE met dezelfde standaard: de automatiek weigert voor een ward,
-    // maar de guardians kunnen shaer:accountMove expliciet openzetten -- en
-    // expliciet dichtzetten geldt dan ook voor een account dat net geen ward
-    // meer is, net als bij de embeds.
-    const isWard = Guardianship.listGuardians(site.slug).length > 0;
-    if (!Guardianship.wardGateAllowed(site.gate_account_move, isWard)) {
-      console.warn('[AP] move refused: gated (shaer-tge):', site.slug, '→', String(targetRaw || ''));
-      return { error: 'guarded_account' };
-    }
-  } catch { /* geen guardianship-tabellen = geen guardians */ }
-  const s = String(targetRaw || '').trim();
-  let targetUri = null;
-  if (/^https?:\/\//i.test(s)) targetUri = safeUrl(s);
-  else if (s.includes('@')) targetUri = await webfingerResolve(s);
-  if (!targetUri) return { error: 'not_found' };
-  const me = actorId(base, site.slug);
-  if (targetUri === me) return { error: 'self' };
-  const target = await (fetchActorFn ? fetchActorFn(targetUri) : signedGetJson(site.slug, targetUri));
-  if (!target || !target.id || !target.inbox) return { error: 'unreachable' };
-  const aka = [].concat(target.alsoKnownAs || [])
-    .map((a) => (typeof a === 'string' ? a : (a && a.id))).filter(Boolean);
-  if (!aka.includes(me)) return { error: 'no_backreference' };
-  db.prepare('UPDATE sites SET moved_to = ? WHERE slug = ?').run(target.id, site.slug);
-  const keys = getOrCreateKeys(site.slug);
-  const move = {
-    '@context': AP_CONTEXT,
-    id: `${me}#move-${Date.now()}-${rid()}`,
-    type: 'Move',
-    actor: me,
-    object: me,
-    target: target.id,
-    to: [`${me}/followers`],
-  };
-  // FEP-7628: after setting movedTo, notify the followers with an Update of
-  // the actor, so their servers hold the signpost even if the Move itself is
-  // lost. Built from the FRESH row: `site` still carries the pre-move values.
-  const movedSite = db.prepare('SELECT * FROM sites WHERE slug = ?').get(site.slug) || { ...site, moved_to: target.id };
-  const update = {
-    '@context': AP_CONTEXT,
-    id: `${me}#update-${Date.now()}-${rid()}`,
-    type: 'Update', actor: me, to: [PUBLIC], cc: [`${me}/followers`],
-    object: buildActor(base, movedSite),
-    published: new Date().toISOString(),
-  };
-  const inboxes = [...new Set(fStmts().list.all(site.slug).map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
-  const send = deliverFn || deliverWithRetry;
-  for (const inbox of inboxes) {
-    await send(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
-    await send(site.slug, inbox, move, `${me}#main-key`, keys.private_pem);
-  }
-  console.log('[AP] MOVE announced:', site.slug, '→', target.id, 'to', inboxes.length, 'inbox(es)');
-  return { ok: true, target: target.id, inboxes: inboxes.length };
-}
-
-// FEP-633c §5.3 note (authorized fetch): true when `actorUri` is a committed
-/**
- * Who is reading this outbox, and what may they see (30-7)?
- *  - 'blocked': a verified caller this instance blocks. They get an EMPTY
- *    collection, not even the public set (Robins eis): a block is a closed
- *    door, and a signed fetch is the caller knocking with their name on it.
- *  - 'friend': the owner (bearer) or a verified accepted follower or
- *    guardian: the fan-only history rides along.
- *  - 'public': everyone else: the public set.
- */
-export function outboxAudience(slug, { bearerSlug = null, verifiedActor = null } = {}) {
-  if (bearerSlug && bearerSlug === slug) return 'friend';
-  if (!verifiedActor) return 'public';
-  if (isBlockedAny(verifiedActor)) return 'blocked';
-  // FEP-1580, Source Instance: wie ondertekend vraagt namens de actor waar wij
-  // NAARTOE verhuisd zijn, moet behandeld worden alsof wij het zelf vragen.
-  // Anders kan de nieuwe instantie alleen het publieke deel ophalen en verhuist
-  // je fan-only geschiedenis niet mee.
-  if (isMoveTarget(slug, verifiedActor)) return 'friend';
-  try {
-    if (db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND actor_uri = ?').get(slug, verifiedActor)) return 'friend';
-  } catch { /* table absent on fresh init */ }
-  if (isWardGuardian(slug, verifiedActor)) return 'friend';
-  return 'public';
-}
-
-/**
- * FEP-1580, de hele autorisatie van de bronkant in één predicaat.
- *
- * De spec zegt: behandel een verzoek dat namens de DOEL-actor getekend is alsof
- * de BRON-actor het deed, voor zichtbaarheid en toegang. Wij hangen dat aan
- * `moved_to`, en dat mag omdat moveAccount() `no_backreference` weigert: het
- * veld komt er alleen te staan als de doel-actor ons al in `alsoKnownAs` had.
- * Dus staat er iets, dan heeft iemand met beheer op BEIDE kanten dat gewild.
- * Een typefout kan hier niet binnenkomen, want die haalt de move zelf niet.
- *
- * Dat dit veilig is leunt op de keyId-binding in verifyRequest (shaer-xd8i):
- * zonder die controle kon een actor tekenen met de sleutel van een buurman op
- * dezelfde host, en dan is "wie tekende dit" te zacht om je hele geschiedenis
- * aan af te geven.
- */
-export function isMoveTarget(slug, actorUri) {
-  if (!slug || !actorUri) return false;
-  try {
-    const row = db.prepare('SELECT moved_to FROM sites WHERE slug = ?').get(slug);
-    return !!(row && row.moved_to && row.moved_to === actorUri);
-  } catch { return false; }
-}
-
-// guardian of the local ward `wardSlug` — so a signed GET from it may read the
-// ward's non-public history without the guardian appearing as a follower.
-export function isWardGuardian(wardSlug, actorUri) {
-  try { return !!Guardianship.getRelation(wardSlug, 'ward', actorUri); } catch { return false; }
-}
-
-// FEP-633c §5.3: the guardians approved a gated follow of their ward. Send the
-// Accept to the follower and record them, so delivery (incl. followers-only)
-// begins. `pending` is a row from ap_pending_follows.
-/**
- * FEP-633c §5.3, the direction that was never gated (bead shaer-p729).
- *
- * A ward's OWN follow waited for nobody: it went straight out and the guardians
- * got a note afterwards (1a2f206). That is informing, not gating — the door is
- * already open when the message lands. Now it waits, with two exceptions that
- * are not favours but the same decision already taken:
- *
- *   - the target is one of the ward's own guardians. Following the adult who
- *     watches over you is not a question anyone needs to answer.
- *   - the target already follows the ward THROUGH THE GATE. A guardian
- *     approved that person by name; asking again about the same person only
- *     teaches everyone to stop reading the question.
- *
- * Returns the held request, or null when the follow may go out now.
- * Deliberately not a boolean: a held follow must be distinguishable from a sent
- * one all the way up to the app, which is the lesson the error path already
- * learned (Robins melding, 31-7).
- */
-export async function gateOutgoingFollow(site, targetUri) {
-  const slug = site && site.slug;
-  if (!slug || !targetUri) return null;
-  const guardians = Guardianship.listGuardians(slug).map((g) => g.other_uri);
-  if (!guardians.length) return null;                                   // not a ward: nothing to gate
-  // shaer:following (shaer-p729) — its own gate, apart from shaer:follows,
-  // which governs the OTHER direction. §5.3 fixes the inbound one on: a Follow
-  // aimed at a ward MUST pass the guardians. About this direction the FEP says
-  // nothing, so it is ours to set and ours to let go of, and the guardians can
-  // relax it for a child who has grown into it. Undecided means gated for a
-  // ward, the same automatiek as the rest of the family.
-  const gateRow = db.prepare('SELECT gate_following FROM sites WHERE slug = ?').get(slug);
-  if (Guardianship.wardGateAllowed(gateRow && gateRow.gate_following, true)) return null;
-  if (guardians.includes(targetUri)) return null;                       // your own guardian
-  if (Guardianship.outgoing.isMutual(slug, targetUri)) return null;     // already vetted by name
-
-  const seen = Guardianship.outgoing.findFor(slug, targetUri);
-  if (seen && seen.status === 'approved') return null;                  // the guardians said yes already
-  if (seen && (seen.status === 'pending' || seen.status === 'denied')) return seen;
-
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const wardActor = actorId(base, slug);
-  const target = await fetchActor(targetUri).catch(() => null);
-  const ti = actorInfo(target, targetUri);
-  const id = `${wardActor}#outfollow-${Date.now()}-${rid()}`;
-  const held = Guardianship.outgoing.recordPending(slug, {
-    id, target: targetUri,
-    inbox: target && ((target.endpoints && target.endpoints.sharedInbox) || target.inbox),
-    name: ti.name, handle: ti.handle, icon: ti.icon,
-  });
-
-  // Same routing as the inbound gate: a guardian on this instance gets a push
-  // and reads /guardian; one elsewhere gets an Offer delivered so its own
-  // server holds a copy to answer from.
-  const wardKeys = getOrCreateKeys(slug);
-  const followObj = { id, type: 'Follow', actor: wardActor, object: targetUri };
-  for (const g of guardians) {
-    try { Guardianship.availability.recordRequest(slug, g, id, Date.now()); } catch { /* never load-bearing */ }
-  }
-  for (const g of guardians) {
-    const gslug = g.startsWith(`${base}/`) ? slugFromActorUrl(g) : null;
-    const isLocal = gslug && db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(gslug);
-    if (isLocal) {
-      const L = pushLang(gslug);
-      // De andere richting, en dus andere woorden: hier vraagt het kind of het
-      // iemand mag volgen. Met dezelfde tekst als hierboven kon een guardian
-      // op zijn telefoon niet zien wie er nu eigenlijk om wie vroeg.
-      pushEvent(gslug, { type: 'guardian', title: i18nT(L, 'push.n_guard_folout_t'), body: i18nT(L, 'push.n_guard_folout_b', { who: ti.name || ti.handle || i18nT(L, 'notif.someone'), ward: slug }), url: `${pushPrefix(gslug)}/guardian` });
-    } else {
-      fetchActor(g).then((ga) => {
-        const inbox = ga && ((ga.endpoints && ga.endpoints.sharedInbox) || ga.inbox);
-        if (!inbox) return;
-        // Zou DIT antwoord het besluit afmaken (shaer-8vt)? Bij twee guardians is de
-        // drempel 1, dus de EERSTE ja beslist -- en dat is precies wat de
-        // beantwoorder niet kon weten.
-        const beslissend = Guardianship.gated.isDecisive(0, Guardianship.follows.followThreshold(guardians.length));
-        const offer = { '@context': AP_CONTEXT, id: `${wardActor}#outfollowoffer-${Date.now()}-${rid()}`, type: 'Offer', actor: wardActor, to: [g], object: followObj, 'shaer:followApproval': true, 'shaer:direction': 'outgoing', 'shaer:decisive': beslissend };
-        deliverWithRetry(slug, inbox, offer, `${wardActor}#main-key`, wardKeys.private_pem).catch(() => {});
-      }).catch(() => {});
-    }
-  }
-  console.log('[AP] outgoing Follow', slug, '→', targetUri, '(gated, awaiting guardians)');
-  return held || { id, ward_slug: slug, target_uri: targetUri, status: 'pending' };
-}
-
-/**
- * The guardians said yes: send the ward's Follow for real (§5.3, shaer-p729).
- *
- * The row stays behind as `approved` rather than being deleted. It is the
- * record that these guardians vetted this target, so an unfollow-and-refollow
- * later does not put the same question in front of them again.
- */
-export async function performApprovedFollow(pending) {
-  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(pending.ward_slug);
-  if (!site) return { error: 'no_such_ward' };
-  const r = await followActor(site, pending.target_uri, false, { approved: true });
-  if (r && r.error) return { error: r.error };
-  console.log('[AP] outgoing Follow approved', pending.ward_slug, '→', pending.target_uri);
-  return { ok: true };
-}
-
-export async function acceptGatedFollow(pending) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const slug = pending.ward_slug;
-  const me = actorId(base, slug);
-  const keys = getOrCreateKeys(slug);
-  fStmts().ins.run(slug, pending.follower_uri, pending.follower_inbox, pending.follower_shared_inbox, pending.follower_name, pending.follower_handle, pending.follower_icon);
-  // This follower came through the §5.3 gate: a guardian said yes to this
-  // person by name. That is precisely what lets the ward follow them back later
-  // without asking the same guardians the same question twice (shaer-p729).
-  db.prepare('UPDATE ap_followers SET gate_approved = 1 WHERE slug = ? AND actor_uri = ?').run(slug, pending.follower_uri);
-  const original = pending.activity_json ? JSON.parse(pending.activity_json) : { type: 'Follow', actor: pending.follower_uri, object: me };
-  const accept = { '@context': AP_CONTEXT, id: `${me}#accept-${Date.now()}-${rid()}`, type: 'Accept', actor: me, object: original };
-  await deliverWithRetry(slug, pending.follower_inbox, accept, `${me}#main-key`, keys.private_pem);
-  const filled = pending.follower_shared_inbox &&
-    db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND shared_inbox = ? AND actor_uri != ? LIMIT 1').get(slug, pending.follower_shared_inbox, pending.follower_uri);
-  if (!filled) backfillNewFollower(base, slug, pending.follower_shared_inbox || pending.follower_inbox).catch(() => {});
-  console.log('[AP] gated Follow accepted', pending.follower_uri, '→ ward', slug);
-  return { ok: true };
-}
-
-// The guardians denied the follow: send a Reject so the follower's server clears
-// its pending state, then the caller drops the record.
-export async function rejectGatedFollow(pending) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const slug = pending.ward_slug;
-  const me = actorId(base, slug);
-  const keys = getOrCreateKeys(slug);
-  const original = pending.activity_json ? JSON.parse(pending.activity_json) : { type: 'Follow', actor: pending.follower_uri, object: me };
-  const reject = { '@context': AP_CONTEXT, id: `${me}#reject-${Date.now()}-${rid()}`, type: 'Reject', actor: me, object: original };
-  if (pending.follower_inbox) await deliverWithRetry(slug, pending.follower_inbox, reject, `${me}#main-key`, keys.private_pem).catch(() => {});
-  console.log('[AP] gated Follow rejected', pending.follower_uri, '→ ward', slug);
-  return { ok: true };
-}
-
-// ── Cross-instance follow-approval (FEP-633c §5.3, modelled on the guardian
-//    offer). Inbound: an Offer(Follow) forwarded by a ward to a guardian (leg
-//    2), or a guardian's Accept/Reject coming back to the ward (leg 4). ──────
-async function handleFollowApprovalInbox(act, slugParam) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const type = Array.isArray(act.type) ? act.type[0] : act.type;
-  const actorUri = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
-
-  // Leg 2: I am a guardian; the object is the Follow to approve. The Offer is
-  // signed by the ward, so act.actor is the ward.
-  if (type === 'Offer') {
-    const fo = (act.object && typeof act.object === 'object') ? act.object : null;
-    const foType = fo && (Array.isArray(fo.type) ? fo.type[0] : fo.type);
-    if (!fo || foType !== 'Follow') return false;
-    const followId = fo.id;
-    const follower = typeof fo.actor === 'string' ? fo.actor : (fo.actor && fo.actor.id);
-    const wardUri = actorUri;
-    if (!followId || !follower || !wardUri) return false;
-    const recips = (Array.isArray(act.to) ? act.to : (act.to ? [act.to] : [])).filter((x) => typeof x === 'string');
-    if (slugParam) recips.push(actorId(base, slugParam));
-    let stored = false;
-    for (const r of new Set(recips)) {
-      const gslug = slugFromActorUrl(r);
-      if (!gslug) continue;
-      if (!Guardianship.getRelation(gslug, 'guardian', wardUri)) continue;   // must actually guard this ward
-      const wardDoc = await fetchActor(wardUri).catch(() => null);
-      const fai = actorInfo(await fetchActor(follower).catch(() => null), follower);
-      // De RICHTING bewaren (shaer-jdb). shaer:direction wordt sinds de uitgaande
-      // gate meegestuurd maar werd nergens gelezen, dus een uitgaande belandde
-      // hier als "deze ward wil deze ward volgen" met het doel weggegooid.
-      // Terugval voor oudere afzenders: is de volger de ward zelf, dan is het
-      // uitgaand -- dat volgt uit de vorm en hoeft niet geloofd te worden.
-      const uitgaand = act['shaer:direction'] === 'outgoing' || follower === wardUri;
-      const doel = uitgaand ? (typeof fo.object === 'string' ? fo.object : (fo.object && fo.object.id)) : null;
-      const dai = uitgaand ? actorInfo(await fetchActor(doel).catch(() => null), doel) : null;
-      Guardianship.follows.recordReview(gslug, {
-        id: followId, wardUri, wardInbox: wardDoc && wardDoc.inbox,
-        follower, followerHandle: fai.handle, followerIcon: fai.icon, followJson: JSON.stringify(fo),
-        direction: uitgaand ? 'outgoing' : 'incoming',
-        target: doel || null, targetHandle: dai ? dai.handle : null,
-      });
-      const L = pushLang(gslug);
-      // `uitgaand` staat hier al, drie regels hoger, en werd voor de melding
-      // weer weggegooid: elke richting kreeg dezelfde tekst, geleend van
-      // offer_for_ward. Op de telefoon las een volgverzoek dus als een
-      // adoptie-aanvraag, en beide richtingen als elkaar.
-      const wardNaam = (wardDoc && (wardDoc.preferredUsername || wardDoc.name)) || slugFromActorUrl(wardUri) || wardUri;
-      const anderNaam = uitgaand
-        ? ((dai && (dai.name || dai.handle)) || i18nT(L, 'notif.someone'))
-        : (fai.name || fai.handle || i18nT(L, 'notif.someone'));
-      pushEvent(gslug, {
-        type: 'guardian',
-        title: i18nT(L, uitgaand ? 'push.n_guard_folout_t' : 'push.n_guard_folin_t'),
-        body: i18nT(L, uitgaand ? 'push.n_guard_folout_b' : 'push.n_guard_folin_b', { who: anderNaam, ward: wardNaam }),
-        url: `${pushPrefix(gslug)}/guardian`,
-      });
-      stored = true;
-    }
-    return stored;
-  }
-
-  // Leg 4: I am the ward; a guardian decided. object is the Follow (id).
-  const fo = act.object;
-  const followId = typeof fo === 'string' ? fo : (fo && fo.id);
-  if (!followId) return false;
-  const pending = Guardianship.follows.getPending(followId);
-  if (!pending) return false;
-  const allGuardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
-  if (!allGuardians.includes(actorUri)) return false;   // only a real guardian of this ward decides
-  const decision = type === 'Reject' ? 'reject' : 'approve';
-  // §3.5: the quorum runs over the AVAILABLE set. The voter itself was
-  // restored by the one-answer rule when its activity arrived, so answering
-  // is exactly what counts a guardian back in.
-  const guardians = Guardianship.availability.availableSet(pending.ward_slug, allGuardians, Date.now());
-  const r = Guardianship.follows.decide(followId, actorUri, decision, guardians);
-  try {
-    if (r.outcome === 'approved') { await acceptGatedFollow(r.follow); Guardianship.follows.remove(followId); }
-    else if (r.outcome === 'rejected') { await rejectGatedFollow(r.follow); Guardianship.follows.remove(followId); }
-  } catch { /* delivery is retried */ }
-  return true;
-}
-
-// Leg 3: a guardian in /guardian decides on a forwarded follow; send the
-// Accept/Reject back to the ward's inbox (signed by the guardian).
-export async function sendFollowDecision(guardianSite, review, decision) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const me = actorId(base, guardianSite.slug);
-  const keys = getOrCreateKeys(guardianSite.slug);
-  const fo = review.follow_json ? JSON.parse(review.follow_json) : { id: review.id, type: 'Follow', actor: review.follower_uri, object: review.ward_uri };
-  const activity = { '@context': AP_CONTEXT, id: `${me}#followdec-${Date.now()}-${rid()}`, type: decision === 'reject' ? 'Reject' : 'Accept', actor: me, to: [review.ward_uri], object: fo, 'shaer:followApproval': true };
-  if (review.ward_inbox) await deliverWithRetry(guardianSite.slug, review.ward_inbox, activity, `${me}#main-key`, keys.private_pem);
-  return { ok: true };
-}
-
-// Send a Like or Announce (boost) on a remote note FROM this site.
-export async function sendInteraction(site, kind, targetNoteId, authorUri) {
-  const _mv = movedRefusal(site, `interaction:${kind}`); if (_mv) return _mv;
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !site || !site.slug || !targetNoteId) return { error: 'config' };
-  const me = actorId(base, site.slug);
-  const keys = getOrCreateKeys(site.slug);
-  // 'unboost' = Undo(Announce): retracts a boost so followers' servers remove the
-  // reblog (matched on actor+object — no record of the original Announce needed).
-  const fanout = (kind === 'boost' || kind === 'unboost'); // also goes to our followers
-  const followersCol = `${me}/followers`;
-  // Address the original author in cc so their server (Mastodon, WordPress/ActivityPub, …)
-  // attributes the boost to their post and notifies them — without this, a shared-inbox
-  // receiver has nothing to route the Announce to. Non-fragment activity ids + a `published`
-  // stamp keep us aligned with what Mastodon emits.
-  const audience = authorUri ? [followersCol, authorUri] : [followersCol];
-  let act;
-  if (kind === 'unboost' || kind === 'unlike') {
-    // Undo(Announce) retracts a boost; Undo(Like) un-favourites (matched on actor+object,
-    // no record of the original activity needed — Mastodon honours both).
-    const inner = kind === 'unboost' ? 'Announce' : 'Like';
-    act = {
-      '@context': AP_CONTEXT,
-      id: `${me}/undo/${Date.now()}-${rid()}`, type: 'Undo', actor: me,
-      object: { id: `${me}/${inner.toLowerCase()}/${Date.now()}-${rid()}`, type: inner, actor: me, object: targetNoteId },
-    };
-    if (kind === 'unboost') { act.to = [PUBLIC]; act.cc = audience; }
-  } else {
-    const type = kind === 'boost' ? 'Announce' : 'Like';
-    act = {
-      '@context': AP_CONTEXT,
-      id: `${me}/${type.toLowerCase()}/${Date.now()}-${rid()}`,
-      type, actor: me, object: targetNoteId,
-    };
-    if (type === 'Announce') { act.published = new Date().toISOString(); act.to = [PUBLIC]; act.cc = audience; }
-  }
-  const inboxes = new Set();
-  // Author first, via their PERSONAL inbox (not the shared one) so a multi-user receiver
-  // routes the Announce/Like to the right post unambiguously.
-  if (authorUri) { const a = await fetchActor(authorUri).catch(() => null); if (a) inboxes.add(a.inbox || (a.endpoints && a.endpoints.sharedInbox)); }
-  if (fanout) { for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox); }
-  // Queue each delivery (immediate attempt + backoff retries on failure via ap_delivery)
-  // instead of a single fire-and-forget POST, so a transient hiccup at the receiver doesn't
-  // silently lose the boost — same durability a new post (deliverCreate) already gets.
-  let queued = 0;
-  for (const inbox of [...inboxes].filter(Boolean)) { deliverWithRetry(site.slug, inbox, act, `${me}#main-key`, keys.private_pem); queued++; }
-  console.log('[AP]', kind, site.slug, '→', targetNoteId, 'queued', queued, 'inbox(es)');
-  return { ok: true, delivered: queued };
-}
-
-// Notifications inbox: new followers + replies/likes/boosts on this site's posts.
-export function getNotifications(slug, limit) {
-  // Per-source cap scales with the requested limit so Messages can page deep
-  // (Load more). Bounded so a huge offset can't ask for unbounded rows.
-  const L = Math.min(1000, Math.max(80, limit || 60));
-  const out = [];
-  try {
-    for (const f of db.prepare('SELECT actor_uri, created_at FROM ap_followers WHERE slug = ? ORDER BY created_at DESC LIMIT ?').all(slug, L)) {
-      out.push({ type: 'follow', handle: deriveHandle(f.actor_uri), url: f.actor_uri, created_at: f.created_at });
-    }
-  } catch { /* ignore */ }
-  try {
-    const rows = db.prepare(`
-      SELECT i.id AS interaction_id, i.kind, i.actor_uri, i.actor_name, i.actor_handle, i.actor_url, i.actor_icon, i.content, i.created_at, i.published, i.visibility,
-             i.emoji_json, i.actor_emoji_json, i.media_json, i.quote_json, i.embed_json,
-             p.slug AS post_slug, p.title AS post_title
-      FROM ap_interactions i LEFT JOIN posts p ON p.id = i.post_id
-      WHERE p.site_id = (SELECT id FROM sites WHERE slug = ?)
-      ORDER BY i.created_at DESC LIMIT ?
-    `).all(slug, L);
-    for (const r of rows) out.push({
-      type: r.kind, name: r.actor_name, handle: r.actor_handle, url: r.actor_url, icon: r.actor_icon,
-      // Waar een antwoord uit de draad heen moet: het id is de parent voor
-      // deliverReply, de uri het adres voor een direct bericht.
-      interactionId: r.interaction_id, actorUri: r.actor_uri,
-      content: stripLeadingMentions(r.content), post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at,
-      // When the post was written, for display. created_at (when it reached us)
-      // stays the sort key and the unread watermark: a note that federated late
-      // is still new to you.
-      published: r.published,
-      emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json,   // FEP-9098 (messages render)
-      media_json: r.media_json, quote_json: r.quote_json, embed_json: r.embed_json,   // rendered like a Krant post
-      // followers/direct = a private message to the owner (not on the public thread) → 🔒 in Messages
-      visibility: r.visibility || 'public',
-    });
-  } catch { /* ignore */ }
-  try {
-    for (const r of db.prepare('SELECT actor_uri, actor_name, actor_handle, actor_icon, content, objects, created_at FROM ap_reports WHERE slug = ? ORDER BY created_at DESC LIMIT ?').all(slug, L)) {
-      // The reported objects: our own notes resolve to post links so the owner
-      // sees WHICH post the report is about; other URIs (e.g. the actor itself)
-      // are skipped — the report row already names the account.
-      const about = [];
-      try {
-        for (const u of JSON.parse(r.objects || '[]')) {
-          const m = String(u).match(/\/ap\/notes\/([^/?#]+)/);
-          if (!m) continue;
-          const p = db.prepare('SELECT slug, title FROM posts WHERE id = ?').get(decodeURIComponent(m[1]));
-          if (p) about.push({ slug: p.slug, title: p.title || p.slug });
-        }
-      } catch { /* malformed objects json → no links */ }
-      out.push({ type: 'report', name: r.actor_name, handle: r.actor_handle, url: r.actor_uri, icon: r.actor_icon, content: r.content, objects: about, created_at: r.created_at });
-    }
-  } catch { /* ignore */ }
-  try {
-    for (const r of db.prepare(`SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, wave, help_request, created_at, published,
-                                       emoji_json, actor_emoji_json, media_json, quote_json, embed_json
-                                FROM ap_mentions WHERE slug = ? ORDER BY created_at DESC LIMIT ?`).all(slug, L)) {
-      out.push({ type: 'mention', name: r.actor_name, handle: r.actor_handle, url: r.actor_url || r.actor_uri, icon: r.actor_icon, content: stripLeadingMentions(r.content), note_url: r.note_url || r.object_uri, wave: r.wave ? 1 : 0, help_request: r.help_request ? 1 : 0, actorUri: r.actor_uri, created_at: r.created_at, published: r.published,
-        // Same trimmings a Krant row has, so Berichten renders the post identically.
-        emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json, media_json: r.media_json, quote_json: r.quote_json, embed_json: r.embed_json });
-    }
-  } catch { /* ignore */ }
-  // Your own polls that have closed → a "results are in" item, derived read-time
-  // from poll_json (Scheduler marks closed=1) with the tally via ownPollView.
-  try {
-    const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(slug);
-    if (site) {
-      const polls = db.prepare(`
-        SELECT id, slug, title, poll_json FROM posts
-        WHERE site_id = ? AND poll_json IS NOT NULL
-          AND json_extract(poll_json, '$.closed') = 1
-          AND json_extract(poll_json, '$.endTime') IS NOT NULL
-        ORDER BY json_extract(poll_json, '$.endTime') DESC LIMIT 20`).all(site.id);
-      for (const p of polls) {
-        const view = ownPollView(p);
-        if (!view) continue;
-        let endTime = null; try { endTime = JSON.parse(p.poll_json).endTime; } catch { /* keep null */ }
-        out.push({ type: 'poll_done', post_slug: p.slug, post_title: p.title, poll: view, created_at: endTime || null });
-      }
-    }
-  } catch { /* ignore */ }
-  // NaN-safe sort: one row with a missing/garbled created_at would otherwise make the
-  // comparator return NaN and scramble the WHOLE ordering (seen live: follow rows landing
-  // between likes, which also broke Messages' like-grouping).
-  out.sort((a, b) => _msgTs(b) - _msgTs(a));
-  return out.slice(0, limit || 60);
-}
-function _msgTs(x) { const t = Date.parse((x && x.created_at) || ''); return Number.isFinite(t) ? t : 0; }
-
-// ── Blocking / defederation ───────────────────────────────────────
-// Extracted to BlocklistService (shared: Klonkt's Block tab + Shaer's "in
-// Orbit"). Thin delegations keep every existing caller working.
-export function listBlocks(slug) { return Blocklist.listBlocks(slug); }
-
-// True if an actor (or its whole domain) is blocked anywhere on this instance.
-
-// Report a remote post or account to its home instance (moderation). Sends the Mastodon-standard
-// AS2 `Flag`: object = [reported account, reported status?], content = the reason, delivered to the
-// reported account's inbox so their instance's moderators receive it. objectUri = a post URL (its
-// author is resolved + included) OR pass actorUri to report an account directly.
-export async function sendReport(site, { objectUri, actorUri, reason }) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !site || !site.slug) return { error: 'config' };
-  let targetActor = actorUri || null;
-  let noteUri = null;
-  if (objectUri && /^https?:\/\//i.test(objectUri)) {
-    const note = await apGetJson(objectUri).catch(() => null);
-    if (note && note.id) { noteUri = note.id; if (!targetActor) targetActor = actorUriOf(note.attributedTo); }
-    else if (!targetActor) return { error: 'not_found' };
-  }
-  if (!targetActor || !/^https?:\/\//i.test(targetActor)) return { error: 'not_found' };
-  const actor = await fetchActor(targetActor).catch(() => null);
-  const inbox = actor && (actor.inbox || (actor.endpoints && actor.endpoints.sharedInbox)); // personal inbox → their moderators
-  if (!inbox) return { error: 'unreachable' };
-  const me = actorId(base, site.slug);
-  const keys = getOrCreateKeys(site.slug);
-  const object = [targetActor];
-  if (noteUri && noteUri !== targetActor) object.push(noteUri);
-  const flag = {
-    '@context': AP_CONTEXT,
-    id: `${me}#report-${Date.now()}-${rid()}`,
-    type: 'Flag',
-    actor: me,
-    content: String(reason == null ? '' : reason).slice(0, 3000),
-    object, // [account, status?] — Mastodon's Flag shape
-    to: [targetActor],
-  };
-  deliverWithRetry(site.slug, inbox, flag, `${me}#main-key`, keys.private_pem);
-  return { ok: true };
-}
-
-export function isBlockedAny(actorUri) { return Blocklist.isBlockedAny(actorUri); }
-
-// Block an actor (@handle or actor URL) or a whole domain; purges their content.
-// The handle resolver is ours; the storage/purge lives in BlocklistService.
-//
-// De BEZORGING hoort ook hier: BlocklistService kent de database, niet het
-// afleveren. Een Block gaat naar de inbox van wie je blokkeert, een
-// Undo(Block) bij het opheffen -- zonder retry-wachtrij, want een blokkade
-// wacht niet op een server die even plat ligt (en bij opheffen komt de ander
-// vanzelf weer langs).
-async function bezorgBlokkade(site, target, undo) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const me = actorId(base, site.slug);
-  const blok = { id: `${me}#block-${Date.now()}-${rid()}`, type: 'Block', actor: me, object: target, to: [target] };
-  const activiteit = undo
-    ? { '@context': AP_CONTEXT, id: `${me}#unblock-${Date.now()}-${rid()}`, type: 'Undo', actor: me, object: blok, to: [target] }
-    : { '@context': AP_CONTEXT, ...blok };
-  const r = await deliverToActor(site, target, activiteit);
-  console.log('[AP]', undo ? 'Undo(Block)' : 'Block', site.slug, '→', target, r && r.delivered ? 'bezorgd' : 'niet bezorgd');
-}
-
-export async function blockTarget(site, input) { return Blocklist.blockTarget(site, input, webfingerResolve, bezorgBlokkade); }
-
-export function unblock(site, target) { return Blocklist.unblock(site, target, bezorgBlokkade); }
-
-// ── Guardianship module wiring (src/services/guardianship/) ────────
-// The module owns FEP-633c (context, relations, handshake, queues, the
-// direct-note leg); we hand it our AP helpers ONCE and delegate. It never
-// imports us back.
-function selfActorId(slug) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  return actorId(base, slug);
-}
-// Deliver one activity to one actor's inbox, signed; queued + retried on any
-// hiccup so a slow or briefly-down ward server never loses the offer. Returns
-// { delivered, inbox }: delivered=false means the account could not be
-// resolved at all (a bad handle) — the offer stays recorded regardless.
-export async function deliverToActor(site, actorUri, activity) {
-  const me = selfActorId(site.slug);
-  const keys = getOrCreateKeys(site.slug);
-  const payload = { '@context': AP_CONTEXT, ...activity };
-  // Co-location is a TRANSPORT detail, never a decision path (Robins regel,
-  // 29-7). An inbox on this machine is not reachable over HTTP from this
-  // machine, and should not be, so a local recipient is handed the activity
-  // straight into the same inbox handler the wire would reach. Everything
-  // above this line therefore behaves as if every Klonkt were remote: one code
-  // path, exercised by every deployment, including the checks. Two bugs in one
-  // day came from having a second, local-only path that hid a broken remote
-  // one.
-  const localSlug = localSlugOf(actorUri);
-  if (localSlug && db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(localSlug)) {
-    const host = (() => { try { return new URL(selfActorId(site.slug)).host; } catch { return ''; } })();
-    const req = { body: payload, ip: 'loopback', protocol: 'https', get: () => host, headers: {} };
-    // The signer is us, and we say so: the actor-versus-signer check runs
-    // exactly as it does over the wire, so a mismatch fails here too.
-    const status = await handleInbox(req, localSlug, { id: me }).catch(() => 500);
-    const ok = status >= 200 && status < 300;
-    console.log('[AP]', activity.type, ok ? 'delivered (loopback) →' : `got ${status} (loopback) from`, actorUri);
-    return { delivered: ok, inbox: `${actorUri}/inbox`, loopback: true, status };
-  }
-  const a = await fetchActor(actorUri).catch(() => null);
-  const inbox = a && (a.inbox || (a.endpoints && a.endpoints.sharedInbox));
-  if (!inbox) {
-    console.warn('[AP] guardianship: could not resolve an inbox for', actorUri, '(offer recorded, not sent)');
-    return { delivered: false, inbox: null };
-  }
-  try {
-    const st = await deliver(inbox, payload, `${me}#main-key`, keys.private_pem);
-    if (st >= 200 && st < 300) { console.log('[AP] guardianship', activity.type, 'delivered →', inbox, st); return { delivered: true, inbox }; }
-    console.warn('[AP] guardianship', activity.type, 'got', st, 'from', inbox, '→ queued for retry');
-  } catch (e) { console.warn('[AP] guardianship', activity.type, 'to', inbox, 'failed:', e.message, '→ queued for retry'); }
-  enqueueDelivery(site.slug, inbox, payload);
-  return { delivered: true, inbox };   // queued: the retry worker gets it there
-}
-Guardianship.wireDelivery({
-  actorId, fetchActor, localActor, deliverTo: deliverToActor, deriveHandle, escHtml, linkUrls, linkHashtags,
-  getOutboxRow: (id) => iStmts().getO.get(id),
-  buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery,
-  // Rijke directe berichten: dezelfde sanitizer als deliverReply gebruikt, zodat
-  // een antwoord uit Berichten door precies één poort gaat.
-  sanitizeHtml: (h) => HtmlSanitizerService.sanitize(h),
-  htmlToPlainText: (h) => HtmlSanitizerService.toPlainText(h),
-});
-/**
- * The actor document of a site WE host, read straight from the database.
- * Same shape fetchActor returns for anyone else, plus `local: true` so the
- * caller can take the loopback instead of a POST to our own hostname.
- * Null for an actor we do not host: that one really is fetched.
- */
-function localActor(actorUri) {
-  const slug = localSlugOf(actorUri);
-  if (!slug) return null;
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
-  if (!site) return null;
-  // primary_slug is what buildActor uses to pick '/' over '/user/<slug>'; the
-  // actor route sets it the same way before building.
-  const p = db.prepare('SELECT slug FROM sites WHERE is_primary = 1').get();
-  try { return { ...buildActor(base, { ...site, primary_slug: p && p.slug }), local: true }; } catch { return null; }
-}
-// Which local site (if any) hosts this actor URI — used by the handshake to
-// apply the local side of a commit and to derive a ward's existing guardians.
-export function localSlugOf(actorUri) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!actorUri || !actorUri.startsWith(`${base}/ap/users/`)) return null;
-  const slug = slugFromActorUrl(actorUri);
-  if (!slug) return null;
-  try { return db.prepare('SELECT slug FROM sites WHERE slug = ?').get(slug) ? slug : null; }
-  catch { return null; }
-}
-Guardianship.wireHandshake({
-  selfId: selfActorId,
-  localSlug: localSlugOf,
-  deliverTo: deliverToActor,
-  deriveHandle,
-  fetchActor,
-  // Guardian PWA / Berichten push. The kid answers an incoming offer in its
-  // own Berichten; an existing guardian and a commit land in the PWA.
-  //
-  // De labels hangen aan dezelfde sleutels als het Guardian-paneel, zodat een
-  // melding en het scherm waar hij heen wijst hetzelfde woord gebruiken.
-  onEvent: (slug, ev) => onGuardianshipEvent(slug, ev),
-});
-
-/**
- * Wat er gebeurt als de guardianship-module iets uitzendt.
- *
- * TWEE VERSCHILLENDE VRAGEN, en ze horen niet dezelfde te zijn: wie maak je
- * WAKKER (push kiest bewust een handvol soorten), en wat moet een scherm dat
- * openstaat WETEN (alles). Het paneel werd daarom voorheen niet gewekt door de
- * tien soorten zonder pushtekst -- die zag je pas bij de volgende tik.
- *
- * Apart en met een naam, zodat een toets erbij kan. Verstopt in de deps-literal
- * was hij onbereikbaar, en een mutatie die het wekken weghaalde bleef groen.
- */
-/**
- * Hoeveel er van bewaard blijft. Een logboek dat oneindig groeit is een
- * logboek dat niemand meer opent, en dit is geschiedenis, geen archief: wat
- * ertoe doet staat vooraan.
- */
-export const GUARDIAN_EVENT_KEEP = 200;
-
-/**
- * Leg de gebeurtenis vast VOOR de melding.
- *
- * De meldingstabel beslist wie er wakker van wordt, en dat is terecht een korte
- * lijst -- maar hij besliste daarmee ook wat er onthouden werd, en dat was niet
- * de bedoeling. Elf van de achttien soorten verdwenen spoorloos, met hun inhoud:
- * een geweigerd aanbod droeg de REDEN mee tot hier en niet verder, terwijl §4.2
- * eist dat de ward en zijn guardians die te horen krijgen.
- *
- * Vastleggen en melden zijn nu twee dingen. Alles komt in het logboek; alleen
- * wat een mens moet wekken gaat ook als push de deur uit.
- */
-export function recordGuardianEvent(slug, ev) {
-  if (!slug || !ev || !ev.kind) return;
-  try {
-    db.prepare('INSERT INTO ap_guardian_events (slug, kind, payload, created_at) VALUES (?,?,?,CURRENT_TIMESTAMP)')
-      .run(slug, String(ev.kind), JSON.stringify(ev));
-    db.prepare(`DELETE FROM ap_guardian_events WHERE slug = ? AND id NOT IN
-                (SELECT id FROM ap_guardian_events WHERE slug = ? ORDER BY id DESC LIMIT ?)`)
-      .run(slug, slug, GUARDIAN_EVENT_KEEP);
-  } catch { /* een logboek mag nooit de gebeurtenis zelf breken */ }
-}
-
-/** De laatste gebeurtenissen voor dit account, nieuwste eerst. */
-export function listGuardianEvents(slug, limit = 50) {
-  try {
-    return db.prepare('SELECT id, kind, payload, created_at FROM ap_guardian_events WHERE slug = ? ORDER BY id DESC LIMIT ?')
-      .all(slug, Math.max(1, Math.min(Number(limit) || 50, GUARDIAN_EVENT_KEEP)))
-      .map((r) => ({ id: r.id, kind: r.kind, created: r.created_at, ...safeJson(r.payload) }));
-  } catch { return []; }
-}
-
-function safeJson(s) { try { return JSON.parse(s) || {}; } catch { return {}; } }
-
-export function onGuardianshipEvent(slug, ev) {
-  recordGuardianEvent(slug, ev);
-  wakeGuardian(slug);
-  const p = guardianEventPush(slug, ev);
-  if (p) pushEvent(slug, p);
-  return p;
-}
-
-/**
- * Welke melding hoort bij een guardianship-gebeurtenis, of geen.
- *
- * Apart en puur, omdat dit een BESLISSING is en geen bezorging: de
- * guardianship-module zendt veertien soorten uit en deze tabel bepaalt welke
- * daarvan een mens wakker maken. Dat hoort toetsbaar te zijn zonder web-push
- * erbij te halen.
- */
-export function guardianEventPush(slug, ev) {
-  const L = pushLang(slug);
-  const texts = {
-    offer_received: ['push.n_guard_offer_t', 'push.n_guard_offer_b'],   // I am the ward
-    offer_for_ward: ['push.n_guard_cog_t', 'push.n_guard_cog_b'],       // I co-guard this ward
-    committed: ['push.n_guard_ward_t', 'push.n_guard_ward_b'],
-    // §3.2: a guardian ended the relation. The ward hears that someone who
-    // was looking after them has gone; a co-guardian hears they are one fewer.
-    guardian_left: ['push.n_guard_left_t', 'push.n_guard_left_b'],
-    coguardian_left: ['push.n_guard_cogleft_t', 'push.n_guard_cogleft_b'],
-    // 5.6 gated settings. Zonder deze twee is de hele tally stil: een guardian
-    // hoort niet dat er een antwoord van hem gewenst is, en dus loopt het
-    // venster leeg en verloopt het voorstel. Een drempel die niemand ziet is
-    // geen drempel.
-    gated_review: ['push.n_gate_ask_t', 'push.n_gate_ask_b'],      // jij moet antwoorden
-    gated_outcome: ['push.n_gate_done_t', 'push.n_gate_done_b'],   // er is besloten
-  }[ev.kind];
-  if (!texts) return null;
-  const who = deriveHandle(ev.candidate || ev.guardian || ev.ward || '') || '?';
-  // Een gate-melding zonder te zeggen WELKE instelling is nutteloos: er zijn er
-  // meer dan een, en ze betekenen heel verschillende dingen voor een kind.
-  const wat = i18nT(L, GATE_LABEL[ev.feature] || 'guardian.prop_embeds');
-  const stand = i18nT(L, ev.value ? 'guardian.prop_on' : 'guardian.prop_off');
-  const uitkomst = i18nT(L, GATE_OUTCOME[ev.outcome] || 'guardian.prop_st_open');
-  const url = (ev.kind === 'offer_received' || ev.kind === 'guardian_left') ? `${pushPrefix(slug)}/messages` : '/guardian';
-  return { type: 'guardian', title: i18nT(L, texts[0]), body: i18nT(L, texts[1], { who, wat, stand, uitkomst }), url };
-}
-
-// Van een gated feature naar het woord dat het Guardian-paneel er al voor
-// gebruikt. Een onbekende feature valt terug op het algemene woord in plaats van
-// de melding te laten vervallen: liever een iets vager bericht dan geen bericht.
-const GATE_LABEL = {
-  'shaer:externalEmbeds': 'guardian.prop_embeds',
-  'shaer:externalPlayback': 'guardian.prop_play',
-};
-const GATE_OUTCOME = {
-  accepted: 'guardian.prop_st_accepted',
-  rejected: 'guardian.prop_st_rejected',
-  expired: 'guardian.prop_st_expired',
-};
-
-// The notification duty of FEP-633c 3.6.2, wired once for every place a
-// dormancy promotion can happen (queue reads, fan-outs, tallies): marking a
-// guardian dormant MUST notify it, in protocol AND over the §6 handle. The
-// one-answer rule is worthless to someone who does not know an answer is
-// wanted. The handle of a committed guardian is its inbox (§6 minimum), which
-// is the same door this delivery knocks on; both attempts are logged.
-Guardianship.wireAvailability({
-  onDormant: (wardSlug, guardianUri) => {
-    const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-    const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(wardSlug);
-    if (!base || !site) return;
-    const me = selfActorId(wardSlug);
-    const note = {
-      id: `${me}/dormant/${Date.now().toString(36)}${rid()}`,
-      type: 'Note', attributedTo: me, to: [guardianUri],
-      'shaer:dormant': true,
-      content: '<p>You have been observed dormant as a guardian. Nothing is wrong and nothing is held against you: one answer restores everything (FEP-633c 3.6.2).</p>',
-    };
-    deliverToActor(site, guardianUri, { id: `${note.id}#create`, type: 'Create', actor: me, to: [guardianUri], object: note })
-      .catch(() => { /* retried by the queue */ });
-    console.log('[AP] guardian observed dormant (3.6.2):', guardianUri, 'ward', wardSlug, '(notified in protocol; the §6 handle is the same inbox)');
-  },
-});
-
-// De C2S-inname zijn werktuigen geven (stap 4, shaer-drc). Onderaan, zodat
-// elke const hierboven al bestaat; een verzoek kan pas na deze evaluatie
-// binnenkomen, dus de koppeling is altijd eerder dan de eerste aanroep.
-wireC2S({
-  proposeGate, deriveHandle, resolveRemoteNote, deliverReply, markRead,
-  postIdFromNoteUrl, sendInteraction, setReaction, gateOutgoingFollow,
-  followActor, unfollowActor, blockTarget, unblock, deliverDelete,
-  deliverOutboxDelete, bakePostContent, bakePostContentWithMentions,
-  deliverCreate,
-});
-// En de tijdlijn-leeskant zijn ene werktuig (stap 5): liked/boosted komen
-// sinds stap 6 uit ap-reactions, maar de koppeling blijft HIER lopen -- twee
-// zustermodules die elkaar importeren zou een kring zijn.
-wireTimeline({ getReactionsFor });
-// Het reactiecluster zijn ene werktuig (stap 6): de verhuisgrendel (FEP-7628).
-wireReactions({ movedLock });
-// De volgwinkel zijn zes werktuigen (stap 7): de verhuisweigering, de
-// §5.3-poortwachter, de actorlezer, de id-staart en de twee bezorgers.
-wireFollowing({ movedRefusal, gateOutgoingFollow, actorInfo, rid, backfillFromOutbox, deliverToActor });
-// De peilingen hun vier werktuigen (stap 8): de Update-bezorging voor de
-// telling, de id-staart, de verhuisweigering en de attributedTo-lezer.
-wirePolls({ deliverUpdate, rid, movedRefusal, actorUriOf });
-// De schakelkast (stap 9): de lijst is bewust lang -- hij is de kaart van wat
-// de inbox aanraakt, en elke naam die eraf gaat is een cluster dat zelf
-// verhuisd is.
-wireInbox({
-  actorInfo, actorUriOf, backfillFromOutbox, backfillNewFollower,
-  belongsInTimeline, contentWarning, emojiJsonOf, fetchNoteAP,
-  findThreadTarget, fStmts, handleFollowApprovalInbox, handleMoveInbox,
-  isBlockedAny, isRejectedObject, iStmts, libraryOwnerSlug, localMentionSlugs,
-  localPostExists, localSlugOf, mediaFromNote, noteVisibility,
-  postIdFromNoteUrl, pushEvent, pushLang, pushPostCtx, pushPrefix,
-  resolveCard, resolveExternalEmbed, resolveQuote, rid, slugFromActorUrl,
-  storeAuthorEmoji, timelineFields, wakeGuardian,
-});
-
-export default {
-  movedLock,
-  // FEP-1580 bronkant. Vergeet je hem hier, dan werpt elke route die hem
-  // aanroept een 500 en lijkt het alsof de poort dicht staat terwijl hij
-  // ontbreekt (precies hoe movedLock zich een dag eerder verstopte).
-  isMoveTarget, signedGetJson, signedGetHeaders,
-  AP_CONTEXT, getOrCreateKeys, apWants, sendAP, actorId, noteId, stripLeadingMentions, pagedCollection,
-  deriveHandle, localSlugOf, outboxSlice, PAGINA_GROOTTE,
-  buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured,
-  channelUrls, channelCategory, timelineFields, guessMediaType,
-  siteOpenTracks, openTrack, buildTrackAudio, buildTrackCollection, buildTrackCreate, trackHostPosts,
-  buildPlaylistCollection, playlistOpenTracks, listPlaylistsAP, playlistLinkTags,
-  buildPostTrackCollection, uitgavePost,
-  buildLibrary, libraryId,
-  followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverObjectDelete, deliverTrackDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
-  feedCursor, feedChangesSince, waitForFeedChange,
-  getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, noteAudience, mayReadNote,
-  listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
-  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, moveAccount, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineRowsByIds, contentWarning, getDirectMessages, readMarkers, markRead, unreadPerConversation, messageRowsByUri, replyRowsByUri, conversationHeads, conversationHistory, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
-  acceptGatedFollow, rejectGatedFollow, isWardGuardian, outboxAudience, sendFollowDecision,
-  gateOutgoingFollow, performApprovedFollow, recordGuardianEvent, listGuardianEvents, GUARDIAN_EVENT_KEEP,
-  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs, previewCard,
-  autoBoostCount, boostedCount, setReaction, getReaction, getReactionsFor, canonicalReactionUri, migrateReactions, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
-  getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
-  deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
-  sendMaybe304, etagFor, onGuardian, wakeGuardian, onGuardianshipEvent, proposeGate, getReplyUris, getThread, filterThreadToCircle, gateAttachments, stripEmojiTags, actorObject, previewObject, quoteObject, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
-  linkifyBody, bakePostContent, bakePostContentWithMentions, listFollowers, removeFollower, listConnections,
-  noteVisibility, belongsInTimeline, playerUrlFor, isRejectedObject, rejectInteraction, interactionReportTarget,
-  getMessages, notificationsSeenAt, ingestOutboxActivity, c2sVisibility, actorDisplay, buildActorRef, prefersEnriched, selfAuthor, getReplyMessages, onNews, wakeNews,
-};
Index: src/services/ArchiveExportService.js
===================================================================
--- src/services/ArchiveExportService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,648 +1,0 @@
-/**
- * Export van een draagbaar inhoudsarchief (shaer-1a6).
- *
- * Bouwt precies wat docs/EXPORT-FORMAT.md beschrijft. Lees dat eerst; hier staat
- * alleen wat de code doet, niet waarom het formaat zo is.
- *
- * Twee dingen zijn geen implementatiedetail maar eis:
- *
- *   REPRODUCEERBAAR  Twee exports van ongewijzigde inhoud horen byte-voor-byte
- *                    gelijk te zijn, anders is een diff of een checksum nutteloos.
- *                    Vandaar gesorteerde sleutels, vaste volgorde, geen tijdstip
- *                    in de postbestanden en een vaste mtime in de zip.
- *   GEEN CREDENTIALS Dit is niet de storage-zip uit shaer-190t. Hier komt geen
- *                    sleutel, sessie, hash of DM van een ander in.
- */
-
-import fs from 'fs';
-import path from 'path';
-import crypto from 'crypto';
-import db, { isoSql } from '../config/database.js';
-import { MEDIA_ROOT, resolveAudioPath } from '../config/paths.js';
-
-// v2: audio zit er eindelijk echt in. Tot v1 kon dat niet: gehoste audio staat
-// BUITEN MEDIA_ROOT (eigen gated route, zie routes/audio.js), en het archief
-// droeg alleen bestanden onder media/. De exporter rekende er met path.relative
-// een /media/../audio/x.mp3 van, en de importer weigerde dat pad terecht. Er
-// stond dus wel een track in de database van de nieuwe site, maar nooit een
-// bestand. v2 heeft een eigen audio/-gebied, exporteert de HELE bibliotheek in
-// plaats van alleen wat in een bericht staat, en neemt de playlists mee.
-export const FORMAT_VERSION = 2;
-
-/** JSON met gesorteerde sleutels: zonder vaste volgorde is byte-gelijkheid toeval. */
-export function stableJson(value) {
-  const sorteer = (v) => {
-    if (Array.isArray(v)) return v.map(sorteer);
-    if (v && typeof v === 'object') {
-      const uit = {};
-      for (const k of Object.keys(v).sort()) if (v[k] !== undefined) uit[k] = sorteer(v[k]);
-      return uit;
-    }
-    return v;
-  };
-  return `${JSON.stringify(sorteer(value), null, 2)}\n`;
-}
-
-const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
-/**
- * Naar ISO 8601 in UTC.
- *
- * SQLite schrijft CURRENT_TIMESTAMP als "2026-07-01 12:56:10" -- in UTC, maar
- * ZONDER zone erbij. Date.parse leest die vorm als LOKALE tijd, en dan schuift
- * elk tijdstempel in het archief mee met de tijdzone van de machine die de export
- * draait. Op een server in Amsterdam is dat twee uur, en dat merk je pas als je
- * ergens anders importeert.
- *
- * Gevonden doordat Bart vroeg of dit wel naar UTC normaliseert. De testmachine
- * draait op UTC, dus geen enkele test kon het zien.
- */
-const toISO = (d) => {
-  if (!d) return null;
-  const s = String(d).trim();
-  const zonderZone = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/.test(s);
-  const t = Date.parse(zonderZone ? `${s.replace(' ', 'T')}Z` : s);
-  return isNaN(t) ? null : new Date(t).toISOString();
-};
-
-const MIME_BY_EXT = {
-  jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
-  webp: 'image/webp', avif: 'image/avif', svg: 'image/svg+xml',
-  mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
-  mp3: 'audio/mpeg', m4a: 'audio/mp4', ogg: 'audio/ogg', wav: 'audio/wav', flac: 'audio/flac',
-};
-const extOf = (u) => ((String(u).split('?')[0].match(/\.(\w+)$/) || [])[1] || '').toLowerCase();
-const mimeOf = (u) => MIME_BY_EXT[extOf(u)] || 'application/octet-stream';
-const as2TypeOf = (mime) => (mime.startsWith('video/') ? 'Video' : mime.startsWith('audio/') ? 'Audio' : 'Image');
-
-/**
- * Van een media-URL naar een bestand op schijf.
- *
- * /media is een kale express.static op MEDIA_ROOT, dus het URL-pad IS het pad
- * onder die map. Een absolute URL naar onze eigen origin telt net zo goed als
- * een pad -- de content slaat allebei op.
- *
- * De ../-controle is geen formaliteit: een verzonnen pad in oude inhoud zou
- * anders een willekeurig bestand van de schijf het archief in trekken.
- */
-function localMediaPath(url, origin) {
-  let p = String(url || '');
-  if (!p) return null;
-  if (/^https?:/i.test(p)) {
-    try {
-      const u = new URL(p);
-      if (`${u.protocol}//${u.host}` !== origin) return null;   // andermans host: nooit van onze schijf
-      p = u.pathname;
-    } catch { return null; }
-  }
-  if (!p.startsWith('/media/')) return null;
-  const abs = path.resolve(MEDIA_ROOT, decodeURIComponent(p.slice('/media/'.length)));
-  const root = path.resolve(MEDIA_ROOT);
-  if (abs !== root && !abs.startsWith(`${root}${path.sep}`)) return null;
-  return abs;
-}
-
-/** Alle media waar een post naar wijst, in vaste volgorde en zonder dubbelen. */
-function mediaRefsOf(post, origin) {
-  const uit = [];
-  const zie = new Set();
-  const voegToe = (url, name, rol, extra = {}) => {
-    const u = String(url || '').trim();
-    if (!u || zie.has(u)) return;
-    zie.add(u);
-    uit.push({ url: u, name: name || null, rol, ...extra });
-  };
-  // De ROL is niet decoratief. Zonder rol staat er in het archief wel een
-  // bestand, maar niet dat het de cover was of bij de speler hoorde -- en dan
-  // komt de post na een herstel zonder cover en zonder speler terug. Gevonden
-  // door bij de oefenherstel ALLE kolommen te vergelijken in plaats van een
-  // handjevol.
-  voegToe(post.cover_image_url, post.cover_alt, 'cover');
-  voegToe(post.cover_video_url, post.cover_alt, 'coverVideo');
-  for (const m of String(post.content || '').matchAll(/<img[^>]+src=["']([^"']+)["'][^>]*>/gi)) voegToe(m[1], null, 'inline');
-  try {
-    for (const a of JSON.parse(post.c2s_attachments || '[]')) {
-      voegToe(a && a.url, a && a.name, 'c2s');
-      // Een audio-bijlage draagt een poster (de omslag die de speler toont). Die
-      // staat in een eigen veld en zou anders stil wegvallen -- op beta viel dat
-      // pas op bij de export van echte data.
-      voegToe(a && a.poster, a && a.name ? `${a.name} (poster)` : null, 'poster', { posterFor: a && a.url });
-    }
-  } catch { /* kapotte kolom blokkeert de export niet */ }
-  // Gehoste audio staat hier NIET meer bij. Die leeft buiten MEDIA_ROOT en gaat
-  // sinds v2 via het audio/-gebied (zie audioBibliotheek). De oude regel rekende
-  // met path.relative een pad naar buiten MEDIA_ROOT uit, en dat kon nooit
-  // aankomen: de importer weigert zo'n pad, terecht.
-  return uit;
-}
-
-/**
- * De audio-metadata die alleen in de database staat en nergens anders uit te
- * halen is: titel, artiest, credit, licentie, externe links.
- *
- * `shaer:media` koppelt de track aan zijn bestand in het archief. Zonder die
- * verwijzing weet een importer wel dát er een track was en hoe hij heette, maar
- * niet wélk van de bijlagen erbij hoort -- en dan valt [[track:]] bij een
- * herstel op niets terug.
- */
-function audioOf(post, audioKaart) {
-  const uit = [];
-  for (const m of String(post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
-    try {
-      const t = db.prepare('SELECT * FROM audio_tracks WHERE id = ?').get(m[1]);
-      if (!t) continue;
-      // Sinds v2 wijst dit naar het audio/-gebied. Staat de track er niet in
-      // (bestand onvindbaar), dan blijft het veld LEEG in plaats van naar een
-      // bijlage te wijzen die er niet is.
-      const bestand = audioKaart.get(t.id) || undefined;
-      uit.push({
-        'shaer:ref': `[[track:${t.id}]]`,
-        'shaer:media': bestand,
-        name: t.title, artist: t.artist || undefined, album: t.album || undefined,
-        duration: t.duration || undefined, credit: t.credit || undefined, license: t.license || undefined,
-        url: [t.link_spotify, t.link_youtube, t.link_soundcloud].filter(Boolean),
-      });
-    } catch { /* idem */ }
-  }
-  return uit.length ? uit : undefined;
-}
-
-/**
- * De HELE audiobibliotheek, plus de playlists.
- *
- * Tot v1 ging alleen mee wat met [[track:]] in een bericht stond. Op
- * sound-fabrics.com waren dat er 14 van de 140, en de 11 playlists gingen
- * helemaal niet mee. Een verhuizing die je bibliotheek achterlaat is geen
- * verhuizing.
- *
- * Het bestand wordt gezocht met resolveAudioPath, dus op DEZELFDE manier als de
- * speler het zoekt. Dat verschil was de stille moordenaar: 124 van de 139
- * storage_paths waren verouderd na een dataverhuizing, de site speelde gewoon
- * door, en de export liet ze weg zonder dat iemand het merkte.
- *
- * @returns {Map<string,string>} trackId -> pad in het archief
- */
-/**
- * Een hoes in het archief leggen.
- *
- * cover_url reisde wel mee als STRING en het bestand niet, dus kwam een track
- * aan met een verwijzing naar een plaatje dat er niet was. Precies dezelfde
- * fout als bij de audio zelf, een laag hoger: een verwijzing zonder bytes.
- *
- * @returns {string|null} het pad in het archief, of null
- */
-function hoesToevoegen(url, origin, bestanden, tellingen) {
-  const schijf = localMediaPath(url, origin);
-  if (!schijf) return null;
-  let bytes = null;
-  try { bytes = fs.readFileSync(schijf); } catch { return null; }
-  const hash = sha256(bytes);
-  const naam = `media/${hash}${extOf(url) ? `.${extOf(url)}` : ''}`;
-  if (!bestanden.has(naam)) { bestanden.set(naam, bytes); tellingen.media += 1; }
-  return naam;
-}
-
-function audioBibliotheek(site, origin, bestanden, tellingen, ontbrekend) {
-  const kaart = new Map();
-  let tracks = [];
-  try {
-    tracks = db.prepare(`SELECT t.*, m.storage_path, m.mime_type FROM audio_tracks t
-                          LEFT JOIN media m ON m.id = t.media_id
-                         WHERE t.site_id = ?
-                         ORDER BY COALESCE(t.position, 999999), t.created_at, t.id`).all(site.id);
-  } catch { return kaart; }              // installatie zonder audio-tabellen
-  if (!tracks.length) return kaart;
-
-  const items = [];
-  for (const t of tracks) {
-    const schijf = resolveAudioPath(t.storage_path, fs);
-    let naam = null;
-    let hash = null;
-    if (schijf) {
-      try {
-        const bytes = fs.readFileSync(schijf);
-        hash = sha256(bytes);
-        const ext = (path.extname(schijf).slice(1) || 'mp3').toLowerCase();
-        naam = `audio/${hash}.${ext}`;
-        if (!bestanden.has(naam)) { bestanden.set(naam, bytes); tellingen.audio += 1; }
-        kaart.set(t.id, naam);
-      } catch { naam = null; }           // onleesbaar telt als ontbrekend, niet als stilte
-    }
-    // Een LINK-ONLY track is geen kapotte track. Klonkt kent dat type: geen
-    // gehost bestand, wel een Spotify- of YouTube-link, en buildNote maakt er
-    // een embed-kaart van (zie trackEmbedLinks). Die tellen dus niet als
-    // ontbrekend, anders meldt de export een probleem dat er niet is.
-    const alleenLinks = !t.media_id && [t.link_spotify, t.link_youtube, t.link_soundcloud].some(Boolean);
-    if (!naam && !alleenLinks) {
-      tellingen.audioMissing += 1;
-      ontbrekend.push({ track: t.title || t.id, url: t.storage_path || '(geen mediarij)' });
-    }
-    if (alleenLinks) tellingen.audioLinks = (tellingen.audioLinks || 0) + 1;
-    items.push({
-      id: t.id, name: t.title || '', artist: t.artist || undefined, album: t.album || undefined,
-      duration: t.duration || undefined, position: t.position ?? undefined,
-      credit: t.credit || undefined, license: t.license || undefined,
-      'shaer:coverUrl': t.cover_url || undefined,
-      // De BYTES van de hoes, niet alleen de verwijzing.
-      'shaer:coverFile': hoesToevoegen(t.cover_url, origin, bestanden, tellingen) || undefined,
-      'shaer:downloadable': t.downloadable ? 1 : 0,
-      'shaer:fediOpen': t.fedi_open ? 1 : 0,
-      'shaer:mediaType': t.mime_type || 'audio/mpeg',
-      'shaer:file': naam || undefined,
-      'shaer:sha256': hash || undefined,
-      // Derde staat, net als bij media: we weten DAT het bestond en waar het
-      // stond. Stil weglaten zou een leugen zijn, en de importer moet hierop
-      // kunnen weigeren in plaats van een track zonder bestand aan te maken.
-      // Drie staten in plaats van twee: erbij, weg, of bewust zonder bestand.
-      'shaer:availability': naam ? 'included' : (alleenLinks ? 'linkOnly' : 'missing'),
-      'shaer:originalPath': naam ? undefined : (t.storage_path || undefined),
-      url: [t.link_spotify, t.link_youtube, t.link_soundcloud].filter(Boolean),
-    });
-  }
-  bestanden.set('tracks.json', Buffer.from(stableJson({
-    '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#' }],
-    type: 'OrderedCollection', 'shaer:archive': true, totalItems: items.length, orderedItems: items,
-  }), 'utf8'));
-  tellingen.tracks = items.length;
-
-  // Playlists: de volgorde IS de playlist, dus die moet expliciet mee.
-  try {
-    const pls = db.prepare('SELECT * FROM playlists WHERE site_id = ? ORDER BY created_at, id').all(site.id);
-    if (pls.length) {
-      const lijst = pls.map((p) => ({
-        id: p.id, name: p.title || '', artist: p.artist || undefined, year: p.year || undefined,
-        'shaer:kind': p.kind || undefined, 'shaer:coverUrl': p.cover_url || undefined,
-        'shaer:coverFile': hoesToevoegen(p.cover_url, origin, bestanden, tellingen) || undefined,
-        'shaer:tracks': db.prepare('SELECT track_id, position FROM playlist_tracks WHERE playlist_id = ? ORDER BY position')
-          .all(p.id).map((r) => ({ id: r.track_id, position: r.position })),
-      }));
-      bestanden.set('playlists.json', Buffer.from(stableJson({
-        '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#' }],
-        type: 'OrderedCollection', 'shaer:archive': true, totalItems: lijst.length, orderedItems: lijst,
-      }), 'utf8'));
-      tellingen.playlists = lijst.length;
-    }
-  } catch { /* geen playlist-tabellen */ }
-
-  return kaart;
-}
-
-/** Eén post als AS2-object volgens het formaat. Bijlagen komen van de beller. */
-function postObject(post, site, origin, attachments, audioKaart) {
-  const heeftTitel = !!(post.title && String(post.title).trim());
-  const published = toISO(post.published_at || post.created_at) || toISO(post.created_at);
-  const updated = toISO(post.updated_at);
-  const poll = (() => {
-    try {
-      const d = JSON.parse(post.poll_json || 'null');
-      if (!d || !Array.isArray(d.options) || d.options.length < 2) return null;
-      const opties = d.options.map((o) => ({ type: 'Note', name: String(o && o.name != null ? o.name : o) }));
-      return { multiple: !!d.multiple, opties, endTime: d.endTime || null, closed: !!d.closed };
-    } catch { return null; }
-  })();
-  const tags = [];
-  try {
-    for (const t of String(post.tags || '').split(',').map((x) => x.trim()).filter(Boolean)) {
-      tags.push({ type: 'Hashtag', name: t.startsWith('#') ? t : `#${t}`, href: `${origin}/tag/${encodeURIComponent(t.replace(/^#/, ''))}` });
-    }
-  } catch { /* tags zijn optioneel */ }
-
-  return {
-    '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#', toot: 'http://joinmastodon.org/ns#', Hashtag: 'as:Hashtag', sensitive: 'as:sensitive' }],
-    id: `${origin}/ap/notes/${encodeURIComponent(post.id)}`,
-    type: poll ? 'Question' : (heeftTitel ? 'Article' : 'Note'),
-    attributedTo: `${origin}/ap/users/${encodeURIComponent(site.slug)}`,
-    name: heeftTitel ? post.title : undefined,
-    content: post.content || '',
-    contentMap: post.language ? { [post.language]: post.content || '' } : undefined,
-    summary: post.content_warning || undefined,
-    sensitive: post.nsfw ? true : undefined,
-    published,
-    updated: (updated && updated !== published) ? updated : undefined,
-    url: `${origin}/${encodeURIComponent(post.slug)}`,
-    attachment: attachments.length ? attachments : undefined,
-    tag: tags.length ? tags : undefined,
-    ...(poll ? (poll.multiple ? { anyOf: poll.opties } : { oneOf: poll.opties }) : {}),
-    endTime: poll ? (poll.endTime || undefined) : undefined,
-    // AS2 kent `closed` op een Question. Zonder dit staat een poll die vroegtijdig
-    // is gesloten na een herstel weer open -- gevonden op echte beta-data.
-    closed: (poll && poll.closed) ? true : undefined,
-    quoteUrl: post.quote_uri || undefined,
-    'shaer:quoteActor': post.quote_actor || undefined,
-    'shaer:slug': post.slug,
-    'shaer:status': post.status || 'draft',
-    'shaer:excerpt': post.excerpt || undefined,
-    'shaer:type': post.type || undefined,
-    'shaer:pinned': post.pinned ? true : undefined,
-    'shaer:noindex': post.noindex ? true : undefined,
-    'shaer:fanOnly': post.fan_only ? true : undefined,
-    'shaer:paid': post.paid ? true : undefined,
-    'shaer:paidMinCents': post.paid ? (post.paid_min_cents || undefined) : undefined,
-    'shaer:apVisibility': post.ap_visibility || undefined,
-    'shaer:publishAt': toISO(post.publish_at) || undefined,
-    'shaer:coverAlt': post.cover_alt || undefined,
-    'shaer:viewCount': post.view_count || undefined,
-    'shaer:audio': audioOf(post, audioKaart),
-  };
-}
-
-/** De leesbare kopie. Afgeleid, eenrichtingsverkeer -- de importer kijkt hier nooit naar. */
-function readableMarkdown(post, obj) {
-  const fm = [
-    '---',
-    `title: ${JSON.stringify(post.title || post.slug)}`,
-    `slug: ${JSON.stringify(post.slug)}`,
-    `date: ${obj.published || ''}`,
-    `status: ${post.status || 'draft'}`,
-    post.content_warning ? `content_warning: ${JSON.stringify(post.content_warning)}` : null,
-    '---',
-  ].filter((l) => l !== null).join('\n');
-  return `${fm}\n${post.content || ''}\n`;
-}
-
-// ── Wie je volgt, als CSV ─────────────────────────────────────────
-//
-// Kolomvorm van Mastodon, zodat deze lijst ook DAAR te importeren is en die van
-// daar hier. Dat is het hele punt van een verhuisformaat: het moet ook werken
-// als je naar iets anders vertrekt dan waar je vandaan kwam.
-//
-//   Account address,Show boosts,Notify on new posts,Languages,Featured
-//
-// `Featured` is onze kolom en draagt `auto_boost`: het vinkje dat in de UI
-// "Uitgelicht" heet (tl.autoboost) en hun posts in jouw Cirkel laat meelopen.
-//
-// `Show boosts` blijft LEEG. Dat is bij Mastodon "toon de reblogs van deze
-// persoon in mijn tijdlijn", en dat kent Klonkt niet. De verleiding is groot om
-// er auto_boost in te schrijven omdat in beide het woord boost zit, maar het is
-// een ander ding: dat van ons gaat over hun eigen posts in JOUW Cirkel, niet
-// over andermans posts die zij doorgeven. `Notify` en `Languages` kent Klonkt
-// evenmin. Die drie staan er omdat Mastodon de POSITIES telt.
-const CSV_KOP = 'Account address,Show boosts,Notify on new posts,Languages,Featured';
-
-/** Een veld dat een komma, aanhalingsteken of nieuwe regel bevat moet geciteerd. */
-function csvVeld(v) {
-  const s = String(v == null ? '' : v);
-  return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
-}
-
-/**
- * De volglijst van een site als CSV, of null als er niets te melden valt.
- *
- * Alleen `accepted`: een openstaand verzoek is geen relatie, en het opnieuw
- * versturen ervan op de nieuwe plek zou een tweede verzoek zijn bij iemand die
- * de eerste misschien bewust liet liggen.
- *
- * Het adres is de handle zonder de leidende @, want zo schrijft Mastodon hem.
- * Ontbreekt de handle, dan valt hij terug op de actor-URI: die is altijd te
- * herleiden, ook als de webfinger-naam ooit verloren ging.
- */
-export function followingCsv(slug) {
-  let rijen = [];
-  try {
-    rijen = db.prepare(`SELECT actor_uri, handle, auto_boost FROM ap_following
-                         WHERE slug = ? AND status = 'accepted'
-                         ORDER BY handle IS NULL, handle, actor_uri`).all(slug);
-  } catch { return null; }        // oude database zonder de kolom
-  if (!rijen.length) return null;
-  const regels = rijen.map((r) => [
-    csvVeld((r.handle || r.actor_uri || '').replace(/^@/, '')),
-    '',                                    // Show boosts: niet van ons
-    '',                                    // Notify on new posts: idem
-    '',                                    // Languages: idem
-    r.auto_boost ? 'true' : 'false',       // Featured: het vinkje "Uitgelicht"
-  ].join(','));
-  return `${CSV_KOP}\n${regels.join('\n')}\n`;
-}
-
-/**
- * Lees zo'n CSV terug. Puur, zodat de vorm te toetsen is zonder database.
- *
- * Vergeeflijk met opzet: een bestand uit Mastodon heeft vier kolommen en geen
- * `Featured`, een handgemaakt bestand heeft misschien alleen adressen. Beide
- * moeten werken, want anders is het geen uitwisselformaat maar een eigen
- * bestandje dat toevallig op een CSV lijkt.
- */
-export function parseFollowingCsv(text) {
-  const uit = [];
-  const regels = String(text || '').split(/\r?\n/).filter((r) => r.trim());
-  if (!regels.length) return uit;
-  // Een kopregel herkennen we aan het eerste veld; anders is regel 1 al data.
-  const start = /^\s*"?account address"?\s*(,|$)/i.test(regels[0]) ? 1 : 0;
-  for (const regel of regels.slice(start)) {
-    const velden = splitsCsvRegel(regel);
-    const adres = (velden[0] || '').trim().replace(/^@/, '');
-    if (!adres) continue;
-    uit.push({
-      address: adres,
-      // Alleen kolom 5. Een bestand uit Mastodon heeft die niet en levert dus
-      // `false`, en dat is juist: hun "Show boosts" in kolom 2 gaat over iets
-      // anders en mag hier niet als uitgelicht binnenkomen.
-      featured: /^(true|1|yes)$/i.test((velden[4] || '').trim()),
-    });
-  }
-  return uit;
-}
-
-/** Eén CSV-regel, met respect voor geciteerde velden en verdubbelde aanhalingstekens. */
-function splitsCsvRegel(regel) {
-  const velden = [];
-  let veld = '';
-  let inCitaat = false;
-  for (let i = 0; i < regel.length; i++) {
-    const c = regel[i];
-    if (inCitaat) {
-      if (c === '"') {
-        if (regel[i + 1] === '"') { veld += '"'; i++; } else inCitaat = false;
-      } else veld += c;
-    } else if (c === '"') inCitaat = true;
-    else if (c === ',') { velden.push(veld); veld = ''; }
-    else veld += c;
-  }
-  velden.push(veld);
-  return velden;
-}
-
-/**
- * Bouw het archief als een lijst bestanden: pad -> inhoud (Buffer).
- *
- * Bewust geen schrijven naar schijf hier: dat maakt de vorm testbaar zonder
- * tijdelijke mappen, en de beller bepaalt of het een map of een zip wordt.
- */
-export function buildArchive(slug, opts = {}) {
-  const origin = (opts.origin || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
-  if (!site) {
-    // De naam van de INSTANCE (de map, de unit) en de slug van de SITE in zijn
-    // database zijn twee dingen. Ze vallen vaak samen en soms niet, en dan zat je
-    // met een foutmelding die je liet raden. Zeg dus wat er wel in staat.
-    let bestaand = [];
-    try { bestaand = db.prepare('SELECT slug FROM sites ORDER BY rowid').all().map((r) => r.slug); } catch { /* geen sites-tabel */ }
-    const wat = slug ? `onbekende site: ${slug}` : 'geen site opgegeven';
-    throw new Error(bestaand.length
-      ? `${wat}. In deze database staat: ${bestaand.join(', ')}`
-      : `${wat}. In deze database staat geen enkele site -- wijst DATABASE_PATH naar de juiste?`);
-  }
-
-  const bestanden = new Map();       // pad -> Buffer
-  const tellingen = { posts: 0, replies: 0, media: 0, mediaMissing: 0, audio: 0, audioMissing: 0, tracks: 0, playlists: 0 };
-  const ontbrekend = [];             // voor de rapportage van de beller
-
-  // De audiobibliotheek EERST. De posts verwijzen ernaar met [[track:]], dus de
-  // kaart moet klaar zijn voor de eerste post gebouwd wordt.
-  const audioKaart = audioBibliotheek(site, origin, bestanden, tellingen, ontbrekend);
-
-  // Vaste volgorde: eerst op publicatiedatum, dan op id. Zonder tweede sleutel
-  // is de volgorde van twee posts op dezelfde seconde niet bepaald.
-  const posts = db.prepare(`SELECT * FROM posts WHERE site_id = ?
-                             ORDER BY ${isoSql('COALESCE(published_at, created_at)')} ASC, id ASC`).all(site.id);
-
-  for (const post of posts) {
-    const attachments = [];
-    for (const ref of mediaRefsOf(post, origin)) {
-      const schijf = localMediaPath(ref.url, origin);
-      const mime = mimeOf(ref.url);
-      let bytes = null;
-      if (schijf) { try { bytes = fs.readFileSync(schijf); } catch { bytes = null; } }
-      if (bytes) {
-        const hash = sha256(bytes);
-        const naam = `media/${hash}${extOf(ref.url) ? `.${extOf(ref.url)}` : ''}`;
-        if (!bestanden.has(naam)) { bestanden.set(naam, bytes); tellingen.media += 1; }
-        attachments.push({
-          type: as2TypeOf(mime), mediaType: mime, name: ref.name || undefined,
-          url: naam, 'shaer:availability': 'included',
-          'shaer:originalUrl': /^https?:/i.test(ref.url) ? ref.url : `${origin}${ref.url}`,
-          'shaer:sha256': hash,
-          'shaer:role': ref.rol,
-          'shaer:posterFor': ref.posterFor || undefined,
-        });
-      } else {
-        // De derde staat uit het formaat: we weten DAT het bestond en waar het
-        // stond, maar we hebben de bytes niet. Stil weglaten zou een leugen zijn.
-        tellingen.mediaMissing += 1;
-        const orig = /^https?:/i.test(ref.url) ? ref.url : `${origin}${ref.url}`;
-        ontbrekend.push({ post: post.slug, url: orig });
-        attachments.push({
-          type: as2TypeOf(mime), mediaType: mime, name: ref.name || undefined,
-          url: orig, 'shaer:availability': 'missing', 'shaer:originalUrl': orig,
-          'shaer:role': ref.rol,
-          'shaer:posterFor': ref.posterFor || undefined,
-        });
-      }
-    }
-
-    const obj = postObject(post, site, origin, attachments, audioKaart);
-    bestanden.set(`posts/${post.id}.json`, Buffer.from(stableJson(obj), 'utf8'));
-    bestanden.set(`readable/${post.slug}.md`, Buffer.from(readableMarkdown(post, obj), 'utf8'));
-    tellingen.posts += 1;
-
-    // Antwoorden van anderen: alleen-lezen archief, nooit opnieuw bezorgd.
-    let replies = [];
-    try {
-      replies = db.prepare(`SELECT * FROM ap_interactions WHERE post_id = ? AND kind = 'reply'
-                             ORDER BY ${isoSql('COALESCE(published, created_at)')} ASC, id ASC`).all(post.id);
-    } catch { /* tabel kan ontbreken op een heel oude database */ }
-    if (replies.length) {
-      const coll = {
-        '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#' }],
-        type: 'OrderedCollection',
-        'shaer:archive': true,
-        'shaer:inReplyTo': obj.id,
-        totalItems: replies.length,
-        orderedItems: replies.map((r) => ({
-          id: r.object_uri || undefined,
-          type: 'Note',
-          attributedTo: r.actor_uri || undefined,
-          inReplyTo: r.parent_uri || obj.id,
-          content: r.content || '',
-          published: toISO(r.published || r.created_at) || undefined,
-          'shaer:actorName': r.actor_name || undefined,
-          'shaer:actorHandle': r.actor_handle || undefined,
-        })),
-      };
-      bestanden.set(`replies/${post.id}.json`, Buffer.from(stableJson(coll), 'utf8'));
-      tellingen.replies += replies.length;
-    }
-  }
-
-  // Wie je volgt. Dit ontbrak, en daarmee was een "verhuizing" halfslachtig: de
-  // Move vertelt je VOLGERS waar je heen ging, maar niets vertelde JOU wie jij
-  // volgde. Die lijst stond alleen in de oude database, en die laat je achter.
-  const volgCsv = followingCsv(slug);
-  if (volgCsv) { bestanden.set('following.csv', Buffer.from(volgCsv, 'utf8')); tellingen.following = volgCsv.trim().split('\n').length - 1; }
-
-  const files = {};
-  for (const pad of [...bestanden.keys()].sort()) files[pad] = sha256(bestanden.get(pad));
-  const manifest = {
-    formatVersion: FORMAT_VERSION,
-    generator: `klonkt/${opts.version || 'dev'}`,
-    exportedAt: opts.exportedAt || new Date().toISOString(),
-    origin,
-    actor: `${origin}/ap/users/${encodeURIComponent(site.slug)}`,
-    site: { slug: site.slug, title: site.title || site.slug },
-    counts: tellingen,
-    files,
-  };
-  bestanden.set('manifest.json', Buffer.from(stableJson(manifest), 'utf8'));
-
-  return { files: bestanden, manifest, counts: tellingen, missing: ontbrekend };
-}
-
-// ── Zip, store-only en deterministisch ────────────────────────────
-// Geen nieuwe afhankelijkheid, en zonder compressie is byte-gelijkheid geen
-// kwestie van vertrouwen in de instellingen van een bibliotheek. De mtime is
-// vast (1980-01-01, de nul van het zip-formaat) om dezelfde reden.
-
-const _crcTabel = (() => {
-  const t = new Int32Array(256);
-  for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; t[n] = c; }
-  return t;
-})();
-function crc32(buf) {
-  let c = 0 ^ -1;
-  for (let i = 0; i < buf.length; i++) c = (c >>> 8) ^ _crcTabel[(c ^ buf[i]) & 0xFF];
-  return (c ^ -1) >>> 0;
-}
-
-export function zipArchive(files) {
-  const paden = [...files.keys()].sort();
-  const lokaal = [];
-  const centraal = [];
-  let offset = 0;
-  for (const pad of paden) {
-    const naam = Buffer.from(pad, 'utf8');
-    const data = files.get(pad);
-    const crc = crc32(data);
-    const lh = Buffer.alloc(30);
-    lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt16LE(0x0800, 6);
-    lh.writeUInt16LE(0, 8);                       // store, geen compressie
-    lh.writeUInt16LE(0, 10); lh.writeUInt16LE(33, 12);   // vaste tijd: 1980-01-01
-    lh.writeUInt32LE(crc, 14); lh.writeUInt32LE(data.length, 18); lh.writeUInt32LE(data.length, 22);
-    lh.writeUInt16LE(naam.length, 26); lh.writeUInt16LE(0, 28);
-    lokaal.push(lh, naam, data);
-
-    const ch = Buffer.alloc(46);
-    ch.writeUInt32LE(0x02014b50, 0); ch.writeUInt16LE(20, 4); ch.writeUInt16LE(20, 6);
-    ch.writeUInt16LE(0x0800, 8); ch.writeUInt16LE(0, 10);
-    ch.writeUInt16LE(0, 12); ch.writeUInt16LE(33, 14);
-    ch.writeUInt32LE(crc, 16); ch.writeUInt32LE(data.length, 20); ch.writeUInt32LE(data.length, 24);
-    ch.writeUInt16LE(naam.length, 28); ch.writeUInt16LE(0, 30); ch.writeUInt16LE(0, 32);
-    ch.writeUInt16LE(0, 34); ch.writeUInt16LE(0, 36); ch.writeUInt32LE(0, 38);
-    ch.writeUInt32LE(offset, 42);
-    centraal.push(ch, naam);
-    offset += 30 + naam.length + data.length;
-  }
-  const cd = Buffer.concat(centraal);
-  const eocd = Buffer.alloc(22);
-  eocd.writeUInt32LE(0x06054b50, 0);
-  eocd.writeUInt16LE(paden.length, 8); eocd.writeUInt16LE(paden.length, 10);
-  eocd.writeUInt32LE(cd.length, 12); eocd.writeUInt32LE(offset, 16);
-  return Buffer.concat([...lokaal, cd, eocd]);
-}
-
-/** Schrijf het archief als losse bestanden naar een map. */
-export function writeArchiveDir(files, dir) {
-  for (const pad of [...files.keys()].sort()) {
-    const doel = path.join(dir, pad);
-    fs.mkdirSync(path.dirname(doel), { recursive: true });
-    fs.writeFileSync(doel, files.get(pad));
-  }
-}
Index: src/services/ArchiveImportService.js
===================================================================
--- src/services/ArchiveImportService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,541 +1,0 @@
-/**
- * Import van een draagbaar inhoudsarchief (shaer-pmr).
- *
- * Leest wat docs/EXPORT-FORMAT.md beschrijft. Vier regels uit dat document zijn
- * geen implementatiekeuze maar eis, en ze staan hier alle vier expliciet:
- *
- *   VERSIE EERST   Een hogere onbekende formatVersion wordt in zijn GEHEEL
- *                  geweigerd. Een half begrepen herstel is erger dan geen
- *                  herstel, want het ziet eruit alsof het gelukt is.
- *   IDENTITEIT     De origin uit het manifest bepaalt of de AP-ids behouden
- *                  blijven. Dat is geen vraag aan de gebruiker: een verkeerd
- *                  antwoord publiceert objecten onder een id dat je niet beheert.
- *   NIETS STILS    Ontbrekende media worden geteld en gemeld.
- *   GEEN UITZENDING Geen Update de fediverse in. Verouderde kopieen elders
- *                  rechttrekken is een aparte, bewuste actie.
- *
- * `readable/` wordt nooit gelezen. Dat is de hele reden dat het afgeleid is.
- */
-
-import fs from 'fs';
-import path from 'path';
-import zlib from 'zlib';
-import crypto from 'crypto';
-import { randomUUID } from 'crypto';
-import db, { NU_ISO } from '../config/database.js';
-import { MEDIA_ROOT, AUDIO_ROOT } from '../config/paths.js';
-import { FORMAT_VERSION, parseFollowingCsv } from './ArchiveExportService.js';
-import * as Migration from './MigrationService.js';
-
-const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
-// De tijdstempel gaat er ONGEWIJZIGD in. Omzetten naar SQL-notatie kostte de
-// sub-seconde, en twee posts in dezelfde seconde staan dan in willekeurige
-// volgorde. Klonkt schrijft zelf ook ISO in deze kolommen.
-const tijd = (iso) => (iso && !isNaN(Date.parse(iso)) ? String(iso) : null);
-
-// ── Inlezen ───────────────────────────────────────────────────────
-
-/** Lees een archiefmap in als pad -> Buffer. */
-export function readArchiveDir(dir) {
-  const files = new Map();
-  const loop = (sub) => {
-    for (const naam of fs.readdirSync(path.join(dir, sub), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
-      const rel = sub ? `${sub}/${naam.name}` : naam.name;
-      if (naam.isDirectory()) loop(rel);
-      else files.set(rel, fs.readFileSync(path.join(dir, rel)));
-    }
-  };
-  loop('');
-  return files;
-}
-
-/**
- * Lees een zip in. Onze eigen export is store-only, maar een archief dat elders
- * gemaakt is mag deflate gebruiken -- anders is het geen uitwisselformaat.
- */
-export function readArchiveZip(buf) {
-  const files = new Map();
-  const eocd = (() => {
-    for (let i = buf.length - 22; i >= 0 && i > buf.length - 66000; i--) if (buf.readUInt32LE(i) === 0x06054b50) return i;
-    return -1;
-  })();
-  if (eocd < 0) throw new Error('geen zip: het eind-record ontbreekt');
-  const aantal = buf.readUInt16LE(eocd + 10);
-  let p = buf.readUInt32LE(eocd + 16);
-  for (let n = 0; n < aantal; n++) {
-    if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error('beschadigde zip: centrale ingang klopt niet');
-    const methode = buf.readUInt16LE(p + 10);
-    const gecomp = buf.readUInt32LE(p + 20);
-    const naamLen = buf.readUInt16LE(p + 28);
-    const extraLen = buf.readUInt16LE(p + 30);
-    const commentLen = buf.readUInt16LE(p + 32);
-    const lokaalOffset = buf.readUInt32LE(p + 42);
-    const naam = buf.toString('utf8', p + 46, p + 46 + naamLen);
-    const lNaam = buf.readUInt16LE(lokaalOffset + 26);
-    const lExtra = buf.readUInt16LE(lokaalOffset + 28);
-    const start = lokaalOffset + 30 + lNaam + lExtra;
-    const rauw = buf.subarray(start, start + gecomp);
-    if (!naam.endsWith('/')) {
-      files.set(naam, methode === 8 ? zlib.inflateRawSync(rauw) : Buffer.from(rauw));
-    }
-    p += 46 + naamLen + extraLen + commentLen;
-  }
-  return files;
-}
-
-export function readArchive(bron) {
-  const st = fs.statSync(bron);
-  return st.isDirectory() ? readArchiveDir(bron) : readArchiveZip(fs.readFileSync(bron));
-}
-
-// ── Importeren ────────────────────────────────────────────────────
-
-/** Een pad onder MEDIA_ROOT houden. Een archief van elders is invoer, geen vriend. */
-function veiligMediaPad(urlPad) {
-  if (!urlPad || !urlPad.startsWith('/media/')) return null;
-  const abs = path.resolve(MEDIA_ROOT, decodeURIComponent(urlPad.slice('/media/'.length)));
-  const root = path.resolve(MEDIA_ROOT);
-  return (abs !== root && abs.startsWith(`${root}${path.sep}`)) ? abs : null;
-}
-
-/** Het pad-deel van een originele media-URL, of null als het er niet een van ons is. */
-function padVanOrigineel(u) {
-  const s = String(u || '');
-  if (s.startsWith('/media/')) return s;
-  try { const x = new URL(s); return x.pathname.startsWith('/media/') ? x.pathname : null; } catch { return null; }
-}
-
-/**
- * Waar deze bijlage komt te staan, als site-relatief pad.
- *
- * Meestal zijn oorspronkelijke plek en bestemming gelijk. Maar een bestand dat
- * ELDERS werd geserveerd -- gehoste audio ging via /audio/stream/ -- heeft geen
- * plek onder /media. Zonder een bestemming zou het bestand wel worden
- * weggeschreven en toch uit de kolommen verdwijnen. Nu krijgt het een eigen hoek,
- * en verwijzen de kolommen daarheen.
- */
-function bestemming(a) {
-  return padVanOrigineel(a && a['shaer:originalUrl']) || `/media/archief/${path.basename(String((a && a.url) || ''))}`;
-}
-
-/**
- * De audiobibliotheek terugzetten (formaat v2).
- *
- * EEN REGEL DIE HIER ALLES BEPAALT: geen bestand, geen track. Dat klinkt
- * vanzelfsprekend en was het niet. De oude per-post-tak maakte een
- * audio_tracks-rij aan zodra er metadata was, ook als de bytes ontbraken. Op
- * soundfabrics.nl leverde dat 13 nummers op die in de lijst stonden en 404'den
- * bij het afspelen. Dat is erger dan ontbreken: het ziet eruit alsof de
- * verhuizing gelukt is, dus je gooit de oude instantie weg.
- *
- * De bestanden gaan naar AUDIO_ROOT en niet onder MEDIA_ROOT, want daar hoort
- * gehoste audio: de publieke /media-handler mag er niet bij (routes/audio.js).
- *
- * @returns {Array} de schrijfopdrachten; de beller voert ze in zijn transactie uit
- * (en bij een droogloop dus niet, maar het verslag klopt wel)
- */
-/**
- * Een hoes uit het archief terugzetten. Geeft het nieuwe /media-pad terug, of
- * null als het bestand er niet in zat: dan liever GEEN cover_url dan een
- * verwijzing naar niets.
- */
-function hoesTerug(files, bestand, werk) {
-  if (!bestand) return null;
-  const bytes = files.get(bestand);
-  if (!bytes || !bytes.length) return null;
-  const naam = path.basename(String(bestand));
-  if (!naam || naam.includes('/') || naam.includes('\\') || naam.startsWith('.')) return null;
-  const urlPad = `/media/archief/${naam}`;
-  const doel = veiligMediaPad(urlPad);
-  if (!doel) return null;
-  werk.push({ soort: 'media', doel, bytes });
-  return urlPad;
-}
-
-function tracksTerug(files, site, rapport) {
-  const buf = files.get('tracks.json');
-  if (!buf) return [];
-  let coll;
-  try { coll = JSON.parse(buf.toString('utf8')); } catch { rapport.waarschuwingen.push('tracks.json is onleesbaar'); return []; }
-  if (coll['shaer:archive'] !== true) { rapport.waarschuwingen.push('tracks.json: niet gemarkeerd als archief, overgeslagen'); return []; }
-
-  const werk = [];
-  for (const t of (coll.orderedItems || [])) {
-    const id = String(t.id || '').trim();
-    if (!id) continue;
-    const bestand = t['shaer:file'];
-    const bytes = bestand ? files.get(bestand) : null;
-    // "Geen bestand" en "niets om te tonen" zijn niet hetzelfde. Een LINK-ONLY
-    // track heeft nooit een bestand gehad: hij bestaat uit een Spotify- of
-    // YouTube-link en Klonkt maakt daar een embed-kaart van. Die hoort gewoon
-    // mee. Mijn eerste regel gooide hem weg, en dat kostte Robin een nummer
-    // (Youngstown) dat op de oude site prima werkte.
-    const links = Array.isArray(t.url) ? t.url.filter(Boolean) : [];
-    const alleenLinks = t['shaer:availability'] === 'linkOnly' || (!bestand && links.length > 0);
-    if ((!bytes || !bytes.length) && !alleenLinks) {
-      rapport.tracksMissing += 1;
-      rapport.waarschuwingen.push(`${t.name || id}: geluidsbestand zit niet in het archief, track niet aangemaakt`);
-      continue;
-    }
-    if (alleenLinks) {
-      // Geen bestand om weg te schrijven, geen mediarij: alleen de track zelf.
-      werk.push({ soort: 'track', id, t, naam: null, bytes: null, doel: null, hoes: hoesTerug(files, t['shaer:coverFile'], werk) });
-      rapport.tracks += 1;
-      rapport.tracksLinks = (rapport.tracksLinks || 0) + 1;
-      continue;
-    }
-    // Naam op de schijf: de hash uit het archief, met zijn extensie. De speler
-    // zoekt op bestandsnaam in AUDIO_ROOT, dus dit is meteen het pad dat werkt.
-    const naam = path.basename(String(bestand));
-    if (!naam || naam.includes('/') || naam.includes('\\') || naam.startsWith('.')) {
-      rapport.waarschuwingen.push(`${t.name || id}: onbruikbare bestandsnaam, overgeslagen`);
-      continue;
-    }
-    const hoes = hoesTerug(files, t['shaer:coverFile'], werk);
-    werk.push({ soort: 'track', doel: path.join(path.resolve(AUDIO_ROOT), naam), bytes, id, t, naam, hoes });
-    rapport.tracks += 1;
-  }
-  return werk;
-}
-
-/** De playlists terug, inclusief hun volgorde: die volgorde IS de playlist. */
-function playlistsTerug(files, site, rapport, bekendeTracks) {
-  const buf = files.get('playlists.json');
-  if (!buf) return [];
-  let coll;
-  try { coll = JSON.parse(buf.toString('utf8')); } catch { rapport.waarschuwingen.push('playlists.json is onleesbaar'); return []; }
-  if (coll['shaer:archive'] !== true) return [];
-  const werk = [];
-  for (const p of (coll.orderedItems || [])) {
-    const id = String(p.id || '').trim();
-    if (!id) continue;
-    // Alleen verwijzen naar tracks die er echt gekomen zijn, anders staat er
-    // straks een playlist vol gaten die niemand kan afspelen.
-    const items = (p['shaer:tracks'] || []).filter((x) => bekendeTracks.has(String(x && x.id)));
-    const kwijt = (p['shaer:tracks'] || []).length - items.length;
-    if (kwijt) rapport.waarschuwingen.push(`playlist ${p.name || id}: ${kwijt} nummer(s) ontbreken en zijn eruit gelaten`);
-    const hoes = hoesTerug(files, p['shaer:coverFile'], werk);
-    werk.push({ soort: 'playlist', p, id, items, hoes });
-    rapport.playlists += 1;
-  }
-  return werk;
-}
-
-/**
- * Volg opnieuw wie je volgde, uit de `following.csv` van een archief.
- *
- * BEWUST BUITEN importArchive. Die draait in één transactie en raakt alleen de
- * database; opnieuw volgen stuurt Follow-activiteiten de deur uit en wacht op
- * het netwerk. Dat hoort niet in een transactie: een trage peer houdt hem open,
- * en een rollback neemt verzonden activiteiten niet terug.
- *
- * Ook een aparte, expliciete stap omdat een archief inlezen stil is maar negen
- * mensen aanschrijven niet. Dat mag geen bijwerking zijn van een import.
- *
- * `followFn` is injecteerbaar, zodat de test geen netwerk raakt en dit bestand
- * ActivityPubService niet hoeft te importeren.
- */
-export async function importFollowing(site, csvText, { followFn = null } = {}) {
-  const rijen = parseFollowingCsv(csvText);
-  const rapport = { totaal: rijen.length, gevolgd: 0, overgeslagen: 0, mislukt: [] };
-  if (!followFn) return { ...rapport, error: 'no_follow_fn' };
-  for (const r of rijen) {
-    // Jezelf volgen is geen relatie maar een lus. Kan echt gebeuren bij een
-    // archief van een instance die je onder een nieuwe naam opnieuw opzet.
-    if (site && site.slug && r.address.startsWith(`${site.slug}@`)) { rapport.overgeslagen += 1; continue; }
-    try {
-      // De uitgelicht-stand gaat MEE in de Follow zelf: followActor neemt hem
-      // als derde argument en zet auto_boost bij het aanmaken van de rij. Een
-      // aparte UPDATE erna zou een tweede pad zijn naar dezelfde vlag, en dan
-      // kan er precies een halve toestand ontstaan als die faalt.
-      const ok = await followFn(site, r.address, r.featured);
-      if (ok === false) { rapport.mislukt.push({ adres: r.address, reden: 'geweigerd' }); continue; }
-      rapport.gevolgd += 1;
-    } catch (e) {
-      rapport.mislukt.push({ adres: r.address, reden: (e && e.message) || 'onbekend' });
-    }
-  }
-  return rapport;
-}
-
-/**
- * Zet een archief terug in een site.
- *
- * @param {Map<string,Buffer>} files  het ingelezen archief
- * @param {object} opts  { slug, dryRun, overwrite, origin }
- */
-export function importArchive(files, opts = {}) {
-  const rapport = {
-    formatVersion: null, origin: null, idsBehouden: null,
-    posts: 0, overgeslagen: 0, overschreven: 0,
-    replies: 0, media: 0, mediaMissing: 0, gemist: [], waarschuwingen: [],
-    tracks: 0, tracksMissing: 0, tracksLinks: 0, playlists: 0, linksBijgetrokken: 0,
-  };
-
-  const manifestBuf = files.get('manifest.json');
-  if (!manifestBuf) throw new Error('geen manifest.json: dit is geen inhoudsarchief');
-  const manifest = JSON.parse(manifestBuf.toString('utf8'));
-  rapport.formatVersion = manifest.formatVersion;
-
-  // VERSIE EERST, voordat er ook maar iets gelezen wordt.
-  if (!Number.isInteger(manifest.formatVersion)) throw new Error('manifest zonder bruikbare formatVersion');
-  if (manifest.formatVersion > FORMAT_VERSION) {
-    throw new Error(`archiefversie ${manifest.formatVersion} is nieuwer dan deze Klonkt kent (${FORMAT_VERSION}); geweigerd`);
-  }
-
-  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(opts.slug);
-  if (!site) {
-    // De naam van de INSTANCE (de map, de unit) en de slug van de SITE in zijn
-    // database zijn twee dingen. Ze vallen vaak samen en soms niet, en dan zat je
-    // met een foutmelding die je liet raden. Zeg dus wat er wel in staat.
-    let bestaand = [];
-    try { bestaand = db.prepare('SELECT slug FROM sites ORDER BY rowid').all().map((r) => r.slug); } catch { /* geen sites-tabel */ }
-    const wat = opts.slug ? `onbekende site: ${opts.slug}` : 'geen site opgegeven';
-    throw new Error(bestaand.length
-      ? `${wat}. In deze database staat: ${bestaand.join(', ')}`
-      : `${wat}. In deze database staat geen enkele site -- wijst DATABASE_PATH naar de juiste?`);
-  }
-  const eigenOrigin = (opts.origin || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  rapport.origin = manifest.origin || null;
-
-  // IDENTITEIT. Het INTERNE id blijft altijd (Robins besluit, 14-8).
-  //
-  // Dat is iets anders dan de AP-URI. Die is domeingebonden en wordt hoe dan
-  // ook nieuw: https://nieuw/ap/notes/<id> is een ander adres dan
-  // https://oud/ap/notes/<id>. Je claimt dus niets van een ander door het GUID
-  // te hergebruiken, en je wint dat elke INTERNE verwijzing blijft kloppen:
-  // [[track:]], [[playlist:]] en [[album:]] wijzen na een verhuizing nog naar
-  // het goede ding.
-  //
-  // Voorheen hing dit aan de origin, en alleen voor posts; tracks en playlists
-  // hielden hun id al wel. Die scheve tabel was precies waarom een post uit de
-  // zip met [[track:oud]] naast een nummer uit de pull met een nieuw id kwam te
-  // staan, en je de shorthand als kale tekst in je bericht zag.
-  //
-  // `idsBehouden` gaat hieronder alleen nog over de AP-URI: gelijke origin
-  // betekent dat ook die identiek blijft, en dan valt er niets te vertalen.
-  const idsBehouden = !!(manifest.origin && eigenOrigin && manifest.origin === eigenOrigin);
-  rapport.idsBehouden = idsBehouden;
-  if (!idsBehouden) {
-    rapport.waarschuwingen.push(
-      `origin verschilt (archief ${manifest.origin || '?'} vs deze site ${eigenOrigin || '?'}): de berichten krijgen een nieuw AP-adres. Hun interne id blijft, dus verwijzingen binnen je site blijven kloppen.`,
-    );
-  }
-
-  const postPaden = [...files.keys()].filter((p) => p.startsWith('posts/') && p.endsWith('.json')).sort();
-  const bestaatId = db.prepare('SELECT 1 FROM posts WHERE id = ?');
-  const bestaatSlug = db.prepare('SELECT id FROM posts WHERE site_id = ? AND slug = ?');
-  const idKaart = new Map();     // oud post-id -> nieuw post-id
-
-  const schrijf = [];            // alles eerst uitrekenen, dan in EEN transactie
-
-  for (const pad of postPaden) {
-    const o = JSON.parse(files.get(pad).toString('utf8'));
-    const oudId = decodeURIComponent(String(o.id || '').split('/ap/notes/')[1] || path.basename(pad, '.json'));
-    // Altijd het id uit het archief. Staat er hier al iets met dat id, dan is
-    // dat hetzelfde object, en dat handelt de botsingscontrole hieronder af.
-    const nieuwId = oudId;
-    idKaart.set(oudId, nieuwId);
-
-    const botsing = !!bestaatId.get(nieuwId) || !!bestaatSlug.get(site.id, o['shaer:slug']);
-    if (botsing && !opts.overwrite) {
-      // EEN gedocumenteerde regel, geen gok per post: bestaande inhoud wordt niet
-      // overschreven tenzij dat expliciet gevraagd is. Dit is ook wat de import
-      // idempotent maakt.
-      rapport.overgeslagen += 1;
-      continue;
-    }
-    if (botsing) rapport.overschreven += 1;
-
-    // Media: terug naar hun oorspronkelijke plek onder /media, want de content
-    // van de post verwijst daarnaar. Dat pad is site-relatief, dus het werkt ook
-    // op een ander domein.
-    for (const a of (Array.isArray(o.attachment) ? o.attachment : [])) {
-      if (a['shaer:availability'] === 'missing') {
-        rapport.mediaMissing += 1;
-        rapport.gemist.push({ post: o['shaer:slug'], url: a['shaer:originalUrl'] || a.url });
-        continue;
-      }
-      const bytes = files.get(a.url);
-      if (!bytes) {
-        // Het archief zegt 'included' maar het bestand ontbreekt. Dat is een kapot
-        // archief, geen ontbrekende media -- apart melden, niet stil optellen.
-        rapport.waarschuwingen.push(`archief verwijst naar ${a.url}, dat er niet in zit`);
-        continue;
-      }
-      if (a['shaer:sha256'] && sha256(bytes) !== a['shaer:sha256']) {
-        rapport.waarschuwingen.push(`${a.url}: checksum klopt niet, overgeslagen`);
-        continue;
-      }
-      const doel = veiligMediaPad(bestemming(a));
-      if (!doel) { rapport.waarschuwingen.push(`${a.url}: onbruikbaar doelpad, overgeslagen`); continue; }
-      schrijf.push({ soort: 'media', doel, bytes });
-      rapport.media += 1;
-    }
-
-    schrijf.push({ soort: 'post', id: nieuwId, oudId, obj: o, oudeUri: o.id || null });
-    rapport.posts += 1;
-  }
-
-  // Antwoorden: alleen-lezen archief. Nooit opnieuw bezorgd, geen meldingen.
-  for (const pad of [...files.keys()].filter((p) => p.startsWith('replies/')).sort()) {
-    const coll = JSON.parse(files.get(pad).toString('utf8'));
-    if (coll['shaer:archive'] !== true) {
-      rapport.waarschuwingen.push(`${pad}: niet gemarkeerd als archief, overgeslagen`);
-      continue;
-    }
-    const oudId = path.basename(pad, '.json');
-    const postId = idKaart.get(oudId);
-    if (!postId) continue;                       // post overgeslagen -> antwoorden ook
-    for (const it of (coll.orderedItems || [])) {
-      schrijf.push({ soort: 'reply', postId, it });
-      rapport.replies += 1;
-    }
-  }
-
-  // De audiobibliotheek. Telt ook in een droogloop mee in het verslag, want
-  // "hoeveel nummers komen er" is precies wat je wilt weten voor je besluit.
-  const trackWerk = tracksTerug(files, site, rapport);
-  const bekendeTracks = new Set(trackWerk.map((w) => w.id));
-  const playlistWerk = playlistsTerug(files, site, rapport, bekendeTracks);
-
-  if (opts.dryRun) return rapport;
-
-  // Schrijven pas nu, in EEN transactie: een half ingelezen archief is de ergste
-  // uitkomst, want dan lijkt het gelukt.
-  const insPost = db.prepare(`INSERT OR REPLACE INTO posts
-    (id, site_id, slug, author_id, title, content, excerpt, status, cover_image_url, cover_alt, cover_video_url,
-     pinned, type, tags, published_at, created_at, updated_at, noindex, publish_at, fan_only, nsfw, language,
-     content_warning, poll_json, quote_uri, quote_actor, ap_visibility, paid, paid_min_cents, view_count, c2s_attachments, origin_server)
-    VALUES (@id, @site_id, @slug, @author_id, @title, @content, @excerpt, @status, @cover_image_url, @cover_alt, @cover_video_url,
-     @pinned, @type, @tags, @published_at, @created_at, @updated_at, @noindex, @publish_at, @fan_only, @nsfw, @language,
-     @content_warning, @poll_json, @quote_uri, @quote_actor, @ap_visibility, @paid, @paid_min_cents, @view_count, @c2s_attachments, 'import')`);
-  const insReply = db.prepare(`INSERT OR IGNORE INTO ap_interactions
-    (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, content, published, parent_uri, created_at)
-    VALUES ('reply', ?, ?, ?, ?, ?, ?, ?, ?, ${NU_ISO})`);
-
-  db.transaction(() => {
-    for (const s of [...schrijf, ...trackWerk, ...playlistWerk]) {
-      if (s.soort === 'media') {
-        fs.mkdirSync(path.dirname(s.doel), { recursive: true });
-        fs.writeFileSync(s.doel, s.bytes);
-        continue;
-      }
-      if (s.soort === 'reply') {
-        insReply.run(s.postId, s.it.id || '', s.it.attributedTo || '', s.it['shaer:actorName'] || null,
-          s.it['shaer:actorHandle'] || null, s.it.content || '', s.it.published || null, s.it.inReplyTo || null);
-        continue;
-      }
-      if (s.soort === 'track') {
-        // Bestand eerst, dan pas de rijen. Faalt het schrijven, dan gooit dit en
-        // rolt de hele transactie terug: liever geen import dan een track zonder
-        // geluid, want dat is precies de val waar dit uit voortkomt.
-        //
-        // Een link-only track heeft geen bestand en dus ook geen mediarij; die
-        // krijgt media_id NULL, precies zoals op de bron.
-        let mediaId = null;
-        if (s.doel && s.bytes) {
-          fs.mkdirSync(path.dirname(s.doel), { recursive: true });
-          fs.writeFileSync(s.doel, s.bytes);
-          mediaId = randomUUID();
-          db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
-            .run(mediaId, site.id, s.naam, s.t['shaer:mediaType'] || 'audio/mpeg', s.bytes.length, s.doel);
-        }
-        const link = (k) => (s.t.url || []).find((u) => String(u).includes(k)) || null;
-        db.prepare(`INSERT OR REPLACE INTO audio_tracks
-            (id, site_id, title, artist, album, duration, media_id, position, credit, license,
-             cover_url, downloadable, fedi_open, link_spotify, link_youtube, link_soundcloud)
-          VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
-          .run(s.id, site.id, s.t.name || 'zonder titel', s.t.artist || null, s.t.album || null,
-            s.t.duration || null, mediaId, s.t.position ?? null, s.t.credit || null, s.t.license || null,
-            s.hoes || null, s.t['shaer:downloadable'] ? 1 : 0, s.t['shaer:fediOpen'] ? 1 : 0,
-            link('spotify'), link('youtube'), link('soundcloud'));
-        continue;
-      }
-      if (s.soort === 'playlist') {
-        db.prepare(`INSERT OR REPLACE INTO playlists (id, site_id, title, artist, year, cover_url, kind)
-                    VALUES (?,?,?,?,?,?,?)`)
-          .run(s.id, site.id, s.p.name || 'zonder titel', s.p.artist || null, s.p.year || null,
-            s.hoes || null, s.p['shaer:kind'] || null);
-        db.prepare('DELETE FROM playlist_tracks WHERE playlist_id = ?').run(s.id);
-        const insPT = db.prepare('INSERT OR IGNORE INTO playlist_tracks (playlist_id, track_id, position) VALUES (?,?,?)');
-        s.items.forEach((it, i) => insPT.run(s.id, String(it.id), it.position ?? i));
-        continue;
-      }
-      const o = s.obj;
-      const opties = (Array.isArray(o.oneOf) ? o.oneOf : (Array.isArray(o.anyOf) ? o.anyOf : null));
-      // De rollen uit het archief terug naar de kolommen. Zonder dit staat het
-      // bestand er wel, maar komt de post zonder cover en zonder speler terug --
-      // en dat zie je pas als je alle kolommen vergelijkt.
-      const bijlagen = Array.isArray(o.attachment) ? o.attachment : [];
-      const padVan = (a) => (a && a['shaer:availability'] !== 'missing' ? bestemming(a) : (a ? padVanOrigineel(a['shaer:originalUrl']) : null));
-      const metRol = (r) => bijlagen.find((a) => a['shaer:role'] === r);
-      const c2s = bijlagen.filter((a) => a['shaer:role'] === 'c2s').map((a) => {
-        const poster = bijlagen.find((x) => x['shaer:role'] === 'poster' && x['shaer:posterFor'] === padVan(a));
-        return {
-          url: padVan(a), mediaType: a.mediaType, name: a.name || undefined,
-          poster: poster ? padVan(poster) : undefined,
-        };
-      }).filter((a) => a.url);
-      insPost.run({
-        id: s.id, site_id: site.id, slug: o['shaer:slug'] || s.id, author_id: site.owner_id,
-        title: o.name || null, content: o.content || '', excerpt: o['shaer:excerpt'] || null,
-        status: o['shaer:status'] || 'draft',
-        cover_image_url: padVan(metRol('cover')), cover_alt: o['shaer:coverAlt'] || null,
-        cover_video_url: padVan(metRol('coverVideo')),
-        c2s_attachments: c2s.length ? JSON.stringify(c2s) : null,
-        pinned: o['shaer:pinned'] ? 1 : 0, type: o['shaer:type'] || 'post',
-        tags: Array.isArray(o.tag) ? o.tag.filter((t) => t && t.type === 'Hashtag').map((t) => String(t.name).replace(/^#/, '')).join(', ') : null,
-        published_at: tijd(o.published), created_at: tijd(o.published), updated_at: tijd(o.updated || o.published),
-        noindex: o['shaer:noindex'] ? 1 : 0, publish_at: tijd(o['shaer:publishAt']),
-        fan_only: o['shaer:fanOnly'] ? 1 : 0, nsfw: o.sensitive ? 1 : 0,
-        language: (o.contentMap && Object.keys(o.contentMap)[0]) || null,
-        content_warning: o.summary || null,
-        poll_json: opties ? JSON.stringify({ multiple: Array.isArray(o.anyOf), options: opties.map((x) => ({ name: x.name })), endTime: o.endTime || null, closed: !!o.closed }) : null,
-        quote_uri: o.quoteUrl || null, quote_actor: o['shaer:quoteActor'] || null,
-        ap_visibility: o['shaer:apVisibility'] || null,
-        paid: o['shaer:paid'] ? 1 : 0, paid_min_cents: o['shaer:paidMinCents'] || null,
-        view_count: o['shaer:viewCount'] || 0,
-      });
-      // De per-post audio-tak is weg. Tracks komen sinds v2 uit tracks.json,
-      // dat de HELE bibliotheek draagt in plaats van alleen wat in een bericht
-      // stond. Hier stond bovendien de fout die soundfabrics.nl opleverde: deze
-      // lus maakte een audio_tracks-rij aan ZONDER te kijken of het bestand er
-      // wel was, dus je kreeg 13 nummers die bestonden, in de lijst stonden, en
-      // 404'den zodra je op play drukte. Zie tracksTerug hieronder.
-    }
-
-    // FEP-1580: een import uit een export is GEEN apart geval. De spec zegt
-    // met zoveel woorden dat objecten uit een geëxporteerde collectie net zo
-    // behandeld moeten worden als objecten die van de bron zijn opgehaald.
-    // Dus vult ook deze weg de vertaaltabel, en werkt de reactie van een derde
-    // op een verhuisd bericht straks net zo goed bij als bij een live ingest.
-    //
-    // Alleen zinnig als de ids VERANDERD zijn: bleven ze gelijk, dan wijst de
-    // oude URI al naar het goede object en valt er niets te vertalen.
-    if (!idsBehouden && eigenOrigin) {
-      for (const s of schrijf) {
-        if (s.soort !== 'post' || !s.oudeUri) continue;
-        Migration.recordMigrated(site.slug, {
-          origin: s.oudeUri,
-          target: `${eigenOrigin}/ap/notes/${encodeURIComponent(s.id)}`,
-          sourceActor: manifest.actor || '',
-          isPublic: !(s.obj && (s.obj['shaer:fanOnly'] || s.obj['shaer:apVisibility'] === 'direct')),
-        });
-      }
-    }
-
-    // Links naar de BRONPOSTS ombuigen naar hier. Ook bij een zip-import: een
-    // verhuizing is een verhuizing, en een bericht dat naar het oude domein
-    // linkt wordt een dode link zodra dat domein opgezegd wordt. Binnen dezelfde
-    // transactie, want half bijgetrokken is erger dan niet.
-    //
-    // Pas hier, aan het eind: nu staan alle berichten er, dus nu weten we welke
-    // slugs bestaan. Een link naar iets dat we niet hebben blijft met rust.
-    if (manifest.origin && manifest.origin !== eigenOrigin) {
-      Migration.postLinksBijtrekken(site, String(manifest.origin).replace(/\/+$/, ''), rapport);
-    }
-  })();
-
-  return rapport;
-}
Index: src/services/ArchiveRecoveryService.js
===================================================================
--- src/services/ArchiveRecoveryService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,250 +1,0 @@
-/**
- * Herstel uit de tijdlijn-cache van een ANDERE Klonkt (shaer-l1v).
- *
- * De aanleiding: boiert.eu verloor zijn database. De posts staan nog in de
- * ap_timeline van instances die boiert volgen, en die tabel sleutelt op de
- * OORSPRONKELIJKE AP-object-URI. De identiteiten overleven dus, en dat is het
- * verschil tussen herstellen en opnieuw posten: boosts, likes en antwoorden
- * elders wijzen naar die ids.
- *
- * Dit maakt geen posts aan. Het maakt een ARCHIEF in het formaat uit
- * docs/EXPORT-FORMAT.md, zodat het door dezelfde importer gaat als een gewone
- * export -- inclusief droogloop, versiecontrole en de regel rond AP-ids. Een
- * apart herstelpad zou een tweede implementatie zijn van iets dat al bestaat.
- *
- * WAT ER PRINCIPIEEL NIET IN ZIT, en dat hoort in de verwachting te staan
- * voordat iemand eraan begint:
- *
- *   - ANTWOORDEN van de verloren site zelf. belongsInTimeline() weigert alles
- *     met een inReplyTo, dus die zijn nooit in een tijdlijn-cache beland.
- *   - alles van VOOR het moment dat de bron ging volgen.
- *   - CONCEPTEN. Nooit gefedereerd, dus nergens gecachet.
- *   - de content is de FEDERATIE-projectie: gesaneerd door de sanitizer van de
- *     bron, met de titel in de tekst gebakken en de afbeeldingen uit de body
- *     gehaald. Waar ze in de tekst stonden is niet te herstellen.
- */
-
-import fs from 'fs';
-import path from 'path';
-import crypto from 'crypto';
-import Database from 'better-sqlite3';
-import { stableJson, FORMAT_VERSION } from './ArchiveExportService.js';
-
-const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
-const MIME_BY_EXT = {
-  jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
-  webp: 'image/webp', avif: 'image/avif', mp4: 'video/mp4', webm: 'video/webm',
-  mov: 'video/quicktime', mp3: 'audio/mpeg', m4a: 'audio/mp4', ogg: 'audio/ogg',
-};
-const extOf = (u) => ((String(u).split('?')[0].match(/\.(\w+)$/) || [])[1] || '').toLowerCase();
-const mimeOf = (u, opgegeven) => opgegeven || MIME_BY_EXT[extOf(u)] || 'application/octet-stream';
-const as2TypeOf = (m) => (m.startsWith('video/') ? 'Video' : m.startsWith('audio/') ? 'Audio' : 'Image');
-
-/**
- * De titel terugvissen uit de tekst.
- *
- * buildNote() zet de titel als eerste alinea in de content -- `<p><strong>...`
- * -- omdat Mastodon `name` negeert. In de cache staat dus de gefedereerde vorm,
- * en zonder deze stap komt elke post titelloos terug met zijn titel als vetgedrukte
- * eerste regel in de body.
- *
- * Er is GEEN sluitend signaal. De slug is niet van de titel afgeleid (op echte
- * data: titel "Back to 1987!", slug "waiting-on-you"), dus we moeten op de vorm
- * afgaan: een openende alinea die niets anders bevat dan vetgedrukte platte
- * tekst. Een post die echt zo begint verliest die regel naar zijn titel. Vandaar
- * dat de beller een lijst terugkrijgt van alles wat is losgetrokken -- dat hoort
- * een mens na te lopen, niet een script.
- */
-export function splitsTitel(html) {
-  const m = String(html || '').match(/^\s*<p>\s*<strong>([^<>]+)<\/strong>\s*<\/p>/i);
-  if (!m) return { titel: null, rest: html || '' };
-  const titel = m[1].replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').trim();
-  if (!titel) return { titel: null, rest: html || '' };
-  return { titel, rest: String(html).slice(m[0].length) };
-}
-
-/**
- * Van een URL naar een bestand op de geredde schijf.
- *
- * Twee routes, en de tweede was bijna vergeten: gewone media gaan via /media/ op
- * MEDIA_ROOT, maar GEHOSTE AUDIO gaat via /audio/stream/<bestandsnaam> op
- * AUDIO_DIR -- een andere map. Op echte cachedata van een muzieksite is dat geen
- * randgeval maar de helft van de bijlagen.
- */
-function schijfPad(url, origin, mediaRoot, audioRoot) {
-  let p = String(url || '');
-  if (/^https?:/i.test(p)) {
-    try {
-      const u = new URL(p);
-      if (origin && `${u.protocol}//${u.host}` !== origin) return null;
-      p = u.pathname;
-    } catch { return null; }
-  }
-  const onder = (root, rest) => {
-    if (!root) return null;
-    const abs = path.resolve(root, decodeURIComponent(rest));
-    const r = path.resolve(root);
-    return (abs !== r && abs.startsWith(`${r}${path.sep}`)) ? abs : null;
-  };
-  if (p.startsWith('/media/')) return onder(mediaRoot, p.slice('/media/'.length));
-  if (p.startsWith('/audio/stream/')) return onder(audioRoot, p.slice('/audio/stream/'.length));
-  return null;
-}
-
-const parse = (s, val = null) => { try { return JSON.parse(s) || val; } catch { return val; } };
-
-/**
- * Bouw een archief uit een of meer tijdlijn-caches.
- *
- * @param {object} opts
- *   sources   paden naar de database(s) van instances die de verloren site volgen
- *   actorUri  de actor van de verloren site, bv. https://boiert.eu/ap/users/boiert
- *   mediaRoot de geredde mediamap van de verloren site (optioneel)
- *   houdTitelInTekst  laat de titel staan waar hij staat
- */
-export function recoverFromCache(opts = {}) {
-  const { sources = [], actorUri, mediaRoot = null, houdTitelInTekst = false } = opts;
-  // AUDIO_PATH staat naast MEDIA_PATH, niet erin. Zonder eigen opgave nemen we de
-  // buurmap van de mediamap, want dat is de standaardindeling van storage/.
-  const audioRoot = opts.audioRoot || (mediaRoot ? path.join(path.dirname(path.resolve(mediaRoot)), 'audio') : null);
-  if (!actorUri) throw new Error('actorUri is verplicht: zonder actor weten we niet wiens posts we redden');
-  const origin = (opts.origin || (() => { try { const u = new URL(actorUri); return `${u.protocol}//${u.host}`; } catch { return ''; } })()).replace(/\/+$/, '');
-  if (!origin) throw new Error('kan de origin niet afleiden uit de actorUri');
-
-  const rapport = {
-    bronnen: [], posts: 0, titels: [], media: 0, mediaMissing: 0, gemist: [],
-    overgeslagen: 0, oudste: null, nieuwste: null, waarschuwingen: [],
-  };
-
-  // Beste rij per AP-id. Meerdere bronnen dekken verschillende periodes, en
-  // dezelfde post kan in meer dan een tijdlijn staan; de rijkste versie wint.
-  const beste = new Map();
-  for (const bron of sources) {
-    let n = 0;
-    let sdb;
-    try { sdb = new Database(bron, { readonly: true, fileMustExist: true }); }
-    catch (e) { rapport.waarschuwingen.push(`${bron}: niet te openen (${e.message})`); continue; }
-    let rijen = [];
-    try { rijen = sdb.prepare('SELECT * FROM ap_timeline WHERE author_uri = ?').all(actorUri); }
-    catch (e) { rapport.waarschuwingen.push(`${bron}: geen bruikbare ap_timeline (${e.message})`); }
-    for (const r of rijen) {
-      // Een boost VAN een ander staat op naam van de oorspronkelijke auteur, dus
-      // author_uri filtert die al weg. Een boost van ONZE post door een ander is
-      // wel van ons -- die houden we, maar zonder de booster.
-      const vorige = beste.get(r.id);
-      if (!vorige || String(r.content || '').length > String(vorige.content || '').length) beste.set(r.id, r);
-      n += 1;
-    }
-    sdb.close();
-    rapport.bronnen.push({ pad: bron, rijen: n });
-  }
-
-  const files = new Map();
-  const ids = [...beste.keys()].sort();
-
-  for (const apId of ids) {
-    const r = beste.get(apId);
-    const postId = decodeURIComponent(String(apId).split('/ap/notes/')[1] || '');
-    if (!postId) { rapport.overgeslagen += 1; continue; }
-    let slug = postId;
-    try { const u = new URL(r.url || ''); slug = decodeURIComponent(u.pathname.replace(/^\//, '')) || postId; } catch { /* val terug op het id */ }
-
-    const gesplitst = houdTitelInTekst ? { titel: null, rest: r.content || '' } : splitsTitel(r.content);
-    if (gesplitst.titel) rapport.titels.push({ slug, titel: gesplitst.titel });
-
-    // Bijlagen. De cache bewaart alleen URL's; de VOLGORDE is die van buildNote,
-    // waarin de cover voorop gaat. Meer signaal is er niet, dus de eerste krijgt
-    // de rol cover en de rest wordt bijlage. Waar ze in de tekst stonden is bij
-    // het federeren verloren gegaan en komt niet terug.
-    const attachments = [];
-    const lijst = parse(r.media_json, []) || [];
-    lijst.forEach((m, i) => {
-      const url = m && (m.url || m.href);
-      if (!url) return;
-      const mime = mimeOf(url, m.type && String(m.type).includes('/') ? m.type : null);
-      const rol = i === 0 ? 'cover' : 'c2s';
-      const bestand = schijfPad(url, origin, mediaRoot, audioRoot);
-      let bytes = null;
-      if (bestand) { try { bytes = fs.readFileSync(bestand); } catch { bytes = null; } }
-      if (bytes) {
-        const hash = sha256(bytes);
-        const naam = `media/${hash}${extOf(url) ? `.${extOf(url)}` : ''}`;
-        if (!files.has(naam)) { files.set(naam, bytes); rapport.media += 1; }
-        attachments.push({
-          type: as2TypeOf(mime), mediaType: mime, name: m.name || m.alt || undefined,
-          url: naam, 'shaer:availability': 'included',
-          'shaer:originalUrl': /^https?:/i.test(url) ? url : `${origin}${url}`,
-          'shaer:sha256': hash, 'shaer:role': rol,
-        });
-      } else {
-        rapport.mediaMissing += 1;
-        rapport.gemist.push({ slug, url });
-        attachments.push({
-          type: as2TypeOf(mime), mediaType: mime, name: m.name || m.alt || undefined,
-          url: /^https?:/i.test(url) ? url : `${origin}${url}`,
-          'shaer:availability': 'missing',
-          'shaer:originalUrl': /^https?:/i.test(url) ? url : `${origin}${url}`,
-          'shaer:role': rol,
-        });
-      }
-    });
-
-    const poll = parse(r.poll_json);
-    const quote = parse(r.quote_json);
-    const opties = poll && Array.isArray(poll.options) && poll.options.length >= 2
-      ? poll.options.map((o) => ({ type: 'Note', name: String(o && o.name != null ? o.name : o) })) : null;
-
-    const obj = {
-      '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#', toot: 'http://joinmastodon.org/ns#', Hashtag: 'as:Hashtag', sensitive: 'as:sensitive' }],
-      id: apId,
-      type: opties ? 'Question' : (gesplitst.titel ? 'Article' : 'Note'),
-      attributedTo: actorUri,
-      name: gesplitst.titel || undefined,
-      content: gesplitst.rest,
-      summary: r.cw || undefined,
-      sensitive: r.nsfw ? true : undefined,
-      published: r.published || undefined,
-      url: r.url || `${origin}/${encodeURIComponent(slug)}`,
-      attachment: attachments.length ? attachments : undefined,
-      ...(opties ? (poll.multiple ? { anyOf: opties } : { oneOf: opties }) : {}),
-      endTime: opties ? (poll.endTime || undefined) : undefined,
-      closed: (opties && poll.closed) ? true : undefined,
-      quoteUrl: (quote && (quote.url || quote.uri || quote.id)) || undefined,
-      'shaer:slug': slug,
-      'shaer:status': 'published',      // alles wat gefedereerd is, was gepubliceerd
-      'shaer:recoveredFrom': 'timeline-cache',
-    };
-    files.set(`posts/${postId}.json`, Buffer.from(stableJson(obj), 'utf8'));
-    files.set(`readable/${slug}.md`, Buffer.from(
-      `---\ntitle: ${JSON.stringify(gesplitst.titel || slug)}\nslug: ${JSON.stringify(slug)}\ndate: ${r.published || ''}\nrecovered: timeline-cache\n---\n${gesplitst.rest}\n`, 'utf8'));
-    rapport.posts += 1;
-    if (r.published) {
-      if (!rapport.oudste || r.published < rapport.oudste) rapport.oudste = r.published;
-      if (!rapport.nieuwste || r.published > rapport.nieuwste) rapport.nieuwste = r.published;
-    }
-  }
-
-  const bestandsHashes = {};
-  for (const pad of [...files.keys()].sort()) bestandsHashes[pad] = sha256(files.get(pad));
-  const manifest = {
-    formatVersion: FORMAT_VERSION,
-    generator: `klonkt-recovery/${opts.version || 'dev'}`,
-    exportedAt: opts.exportedAt || new Date().toISOString(),
-    origin,
-    actor: actorUri,
-    site: { slug: opts.slug || '', title: opts.title || '' },
-    counts: { posts: rapport.posts, replies: 0, media: rapport.media, mediaMissing: rapport.mediaMissing },
-    files: bestandsHashes,
-    // Zodat niemand dit later voor een gewone export aanziet: dit archief is
-    // gereconstrueerd uit andermans cache en is per definitie onvolledig.
-    'shaer:recovered': {
-      from: 'timeline-cache',
-      sources: rapport.bronnen.map((b) => path.basename(b.pad)),
-      window: { oldest: rapport.oudste, newest: rapport.nieuwste },
-      missing: ['replies by this actor', 'posts from before the source followed', 'drafts'],
-    },
-  };
-  files.set('manifest.json', Buffer.from(stableJson(manifest), 'utf8'));
-
-  return { files, manifest, rapport };
-}
Index: src/services/AudioEmbedService.js
===================================================================
--- src/services/AudioEmbedService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/services/AudioEmbedService.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -11,48 +11,13 @@
  */
 
-// Dezelfde lijst soorten als de server en de editor gebruiken. Zie
-// assets/js/shared/post-music-type.js: die module is puur, dus hij mag hier.
-import { SOORTEN } from '../assets/js/shared/post-music-type.js';
-
-// "Open in" icons (brand-colored via CSS .pat-link--).
-const OPEN_IN_SVG = {
-  spotify: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2a10 10 0 100 20 10 10 0 000-20zm4.6 14.42a.62.62 0 01-.86.21c-2.35-1.44-5.3-1.76-8.79-.96a.62.62 0 11-.28-1.21c3.8-.87 7.07-.5 9.71 1.11.3.18.39.57.22.85zm1.23-2.73a.78.78 0 01-1.07.26c-2.69-1.66-6.79-2.14-9.97-1.17a.78.78 0 11-.45-1.49c3.63-1.1 8.15-.56 11.24 1.33.36.22.48.7.25 1.07zm.1-2.85C14.66 8.95 9.4 8.78 6.3 9.72a.93.93 0 11-.54-1.79c3.56-1.08 9.37-.87 13.07 1.33a.94.94 0 01-.96 1.61z"/></svg>',
-  youtube: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 7.1a3 3 0 00-2.1-2.12C19.04 4.5 12 4.5 12 4.5s-7.04 0-8.9.48A3 3 0 001 7.1 31.2 31.2 0 00.5 12 31.2 31.2 0 001 16.9a3 3 0 002.1 2.12c1.86.48 8.9.48 8.9.48s7.04 0 8.9-.48A3 3 0 0023 16.9 31.2 31.2 0 0023.5 12 31.2 31.2 0 0023 7.1zM9.75 15.5v-7l6 3.5-6 3.5z"/></svg>',
-  soundcloud: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 14v4M7.5 11v7M11 9v9"/><path d="M14.5 9.5V18h4a3 3 0 100-6 4 4 0 00-4-2.5z"/></svg>',
-};
-
 class AudioEmbedService {
-  // Small "open in" links for a track (Spotify/YouTube/SoundCloud). The hrefs
-  // are already validated server-side (https + correct host only). Returns ''
-  // when no links exist. Placed next to the play button (outside the button →
-  // no conflict with playback).
-  static openInLinks(t) {
-    if (!t) return '';
-    const out = [];
-    const add = (url, key, label) => {
-      if (!url) return;
-      out.push(`<a class="pat-link pat-link--${key}" href="${this.escape(url)}" target="_blank" rel="noopener noreferrer" title="Open in ${label}" aria-label="Open in ${label}">${OPEN_IN_SVG[key]}</a>`);
-    };
-    add(t.link_spotify, 'spotify', 'Spotify');
-    add(t.link_youtube, 'youtube', 'YouTube');
-    add(t.link_soundcloud, 'soundcloud', 'SoundCloud');
-    return out.length ? `<span class="pat-links">${out.join('')}</span>` : '';
-  }
-
   static detectProvider(url) {
     if (!url || typeof url !== 'string') return null;
     url = url.trim();
 
-    // Only embed http(s) URLs. The provider regexes below are NOT anchored,
-    // so without this check e.g. `javascript:alert(1)//youtu.be/x` would match
-    // and land as an embed URL (stored XSS via an [[embed:...]] shortcode —
-    // that text never passes through the HTML sanitizer because it lives in a
-    // text node). The scheme guard excludes javascript:/data:/vbscript: etc.
-    if (!/^https?:\/\//i.test(url)) return null;
-
     // Spotify
     if (/open\.spotify\.com\/(track|album|playlist|episode|show)\/([A-Za-z0-9]+)/i.test(url)) {
       const match = url.match(/\/(track|album|playlist|episode|show)\/([A-Za-z0-9]+)/i);
-      return { provider: 'spotify', type: match[1], id: match[2], url };
+      return { provider: 'spotify', type: match[1], id: match[2] };
     }
 
@@ -72,35 +37,8 @@
     }
 
-    // YouTube — a video id is always exactly 11 characters (aligns with the
-    // client-side ytId() in embed-player.js, which also expects {11}).
-    //
-    // A link may carry a video, a playlist, or both, and until now we kept only
-    // the video and threw `list=` away -- so a link to an album played its first
-    // song and stopped. The ref now keeps whichever is there, in the same three
-    // shapes the Klonkt hub uses, so one ref travels between the two unchanged:
-    //
-    //   "<video>"           one video
-    //   "<video>?list=<L>"  that video, and on through the list
-    //   "list:<L>"          the whole playlist (YouTube's `videoseries`)
-    //
-    // `list` may sit before or after `v=` and is often entity-encoded (&amp;)
-    // in a baked href, hence the scan over the whole URL rather than a fixed
-    // order. A list id is 10-60 chars: longer and looser than a video id.
-    if (/(?:youtube(?:-nocookie)?\.com\/(?:watch\?|playlist\?|embed\/|shorts\/|live\/)|youtu\.be\/)/i.test(url)) {
-      const vm = url.match(/(?:[?&](?:amp;)?v=|youtu\.be\/|\/embed\/|\/shorts\/|\/live\/)([A-Za-z0-9_-]{11})(?![A-Za-z0-9_-])/i);
-      const lm = url.match(/[?&](?:amp;)?list=([A-Za-z0-9_-]{10,60})/i);
-      // `videoseries` is a marker, not a video: a bare playlist embed URL reads
-      // /embed/videoseries?list=..., and taking that for an id gives a dead
-      // frame. It is EXACTLY eleven characters, so no length rule catches it --
-      // it has to be named. (Measured, not assumed: it slipped through a
-      // boundary check that looked like it covered this.)
-      const id = vm && vm[1] !== 'videoseries' ? vm[1] : null;
-      const list = lm ? lm[1] : null;
-      if (id || list) {
-        const ref = id ? (list ? `${id}?list=${list}` : id) : `list:${list}`;
-        // `id` stays exactly what it was for every caller that only wants a
-        // video; `list` and `ref` are additions.
-        return { provider: 'youtube', id, list, ref, url };
-      }
+    // YouTube
+    if (/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([A-Za-z0-9_-]{6,20})/i.test(url)) {
+      const match = url.match(/(?:v=|youtu\.be\/|embed\/)([A-Za-z0-9_-]{6,20})/i);
+      return { provider: 'youtube', id: match[1] };
     }
 
@@ -108,64 +46,22 @@
     if (/vimeo\.com\/(?:video\/)?(\d+)/i.test(url)) {
       const match = url.match(/\d+/);
-      return { provider: 'vimeo', id: match[0], url };
+      return { provider: 'vimeo', id: match[0] };
     }
 
     return null;
-  }
-
-  // Direct media files (video/audio) hosted anywhere → a native <video>/<audio>
-  // player. Kept OUT of detectProvider() on purpose: the timeline/cover callers
-  // switch on provider slugs (youtube/spotify/…) and a bare file has none, so
-  // overloading detectProvider would suppress e.g. a PeerTube fallback. Only
-  // autoembed() and [[embed:…]] use this.
-  static MEDIA_FILE_EXT = {
-    video: ['mp4', 'webm', 'm4v', 'mov', 'ogv'],
-    audio: ['mp3', 'ogg', 'oga', 'wav', 'm4a', 'flac', 'opus', 'aac'],
-  };
-
-  static detectMediaFile(url) {
-    if (!url || typeof url !== 'string') return null;
-    if (!/^https?:\/\//i.test(url)) return null;
-    let pathname;
-    try { pathname = new URL(url).pathname.toLowerCase(); } catch { return null; }
-    const ext = (pathname.match(/\.([a-z0-9]+)$/) || [])[1];
-    if (!ext) return null;
-    if (this.MEDIA_FILE_EXT.video.includes(ext)) return { kind: 'video', url };
-    if (this.MEDIA_FILE_EXT.audio.includes(ext)) return { kind: 'audio', url };
-    return null;
-  }
-
-  static mediaFileEmbed(url) {
-    const m = this.detectMediaFile(url);
-    if (!m) return null;
-    const src = this.escape(m.url);
-    if (m.kind === 'video') {
-      return `<figure class="folio-embed folio-embed--video"><video src="${src}" controls preload="metadata" playsinline></video></figure>`;
-    }
-    return `<figure class="folio-embed folio-embed--audio"><audio src="${src}" controls preload="metadata"></audio></figure>`;
   }
 
   static generateIframe(provider, config) {
     switch (provider) {
-      // Custom players (client-side via embed-player.js + the real platform APIs).
-      // We render a placeholder with data attributes instead of the bare platform
-      // iframe, so the embed appears in OUR brand style.
-      case 'youtube':
-        // The ref carries the list when there is one; `id` alone would drop it
-        // and play a single song out of an album.
-        return this.embedPlaceholder('youtube', config.ref || config.id, 'video',
-          config.url || (config.id ? `https://youtu.be/${config.id}`
-                                   : `https://www.youtube.com/playlist?list=${config.list}`));
-      case 'soundcloud':
-        return this.embedPlaceholder('soundcloud', config.url, 'track', config.url);
       case 'spotify':
-        return this.embedPlaceholder('spotify', `spotify:${config.type}:${config.id}`,
-          config.type, config.url || `https://open.spotify.com/${config.type}/${config.id}`);
-      // No JS API (Bandcamp/Apple) or low priority (Vimeo): remain as iframes;
-      // mutual exclusion for these runs via the blur fallback.
+        return this.spotifyIframe(config);
       case 'bandcamp':
         return this.bandcampIframe(config);
+      case 'soundcloud':
+        return this.soundcloudIframe(config);
       case 'applemusic':
         return this.applemusicIframe(config);
+      case 'youtube':
+        return this.youtubeIframe(config);
       case 'vimeo':
         return this.vimeoIframe(config);
@@ -173,19 +69,4 @@
         return null;
     }
-  }
-
-  /**
-   * Placeholder for a custom player. embed-player.js picks up
-   * .folio-embed[data-embed-provider] and builds the card + player client-side.
-   * ALL values go through escape() — post.content_html is executed unescaped.
-   */
-  static embedPlaceholder(provider, ref, type, url) {
-    const attrs = [
-      `data-embed-provider="${this.escape(provider)}"`,
-      `data-embed-ref="${this.escape(ref)}"`,
-      type ? `data-embed-type="${this.escape(type)}"` : '',
-      `data-embed-url="${this.escape(url)}"`,
-    ].filter(Boolean).join(' ');
-    return `<div class="folio-embed folio-embed--${this.escape(provider)} pcms-embed pcms-embed-card pcms-embed-loading" ${attrs}></div>`;
   }
 
@@ -243,31 +124,11 @@
 
   static applemusicIframe({ url }) {
-    // Een album of nummer heeft een NUMMER als id, een afspeellijst niet: die
-    // heet `pl.u-LdbqzVvI3go5g`. Met alleen [0-9]+ viel elke playlist hier af
-    // en gaf deze functie null -- waarna de shortcode zelf op de pagina kwam.
-    // Barts melding (17-8): het concept "The Mixtape" toonde in preview
-    // letterlijk [[embed:https://music.apple.com/nl/playlist/...]].
-    //
-    // Bewust krap: geen slash, vraagteken of hekje in het id, want wat hier
-    // gevangen wordt gaat rechtstreeks achter https://embed.music.apple.com/ aan.
-    const match = url.match(
-      /music\.apple\.com\/([a-z]{2}\/(album|playlist|song)\/[^/?#]+\/(?:[0-9]+|pl\.[A-Za-z0-9_-]+))/i,
-    );
+    const match = url.match(/music\.apple\.com\/([a-z]{2}\/(?:album|playlist|song)\/[^/?#]+\/[0-9]+)/i);
     if (!match) return null;
     const src = `https://embed.music.apple.com/${match[1]}`;
-    // De hoogte hangt af van WAT je insluit, en dat stond hier op een vaste
-    // 175px -- de maat van een LOS NUMMER. Een album of afspeellijst is 450px,
-    // dus daarvan zag je ongeveer een derde, met `overflow:hidden` eroverheen
-    // zodat de rest ook niet te bereiken viel. Barts melding (20-8) over
-    // boiert.eu/the-mixtape.
-    //
-    // Nagemeten en niet overgenomen: de embed-pagina van die lijst
-    // (pl.u-LdbqzVvI3go5g) geeft zijn <main> EN zijn <body> allebei precies
-    // 450px. Dat is ook de hoogte in Apple's eigen insluitcode.
-    const hoogte = String(match[2]).toLowerCase() === 'song' ? 175 : 450;
     return `
       <figure class="folio-embed folio-embed--applemusic">
         <iframe src="${this.escape(src)}"
-                style="width:100%;height:${hoogte}px;border:0;overflow:hidden;border-radius:8px;"
+                style="width:100%;height:175px;border:0;overflow:hidden;border-radius:8px;"
                 loading="lazy"
                 allow="autoplay; clipboard-write; encrypted-media"
@@ -277,23 +138,6 @@
   }
 
-  /**
-   * The plain provider iframe. Takes the same ref shapes as the placeholder:
-   * "<video>", "<video>?list=<L>" and "list:<L>" -- a bare playlist embeds as
-   * `videoseries`. Kept in step with the placeholder path on purpose: this is
-   * the fallback, and a fallback that silently drops the playlist is the worst
-   * kind, because it looks like it worked.
-   */
-  static youtubeIframe({ id, ref }) {
-    const r = ref || id || '';
-    const base = 'https://www.youtube-nocookie.com/embed/';
-    let src;
-    if (r.startsWith('list:')) {
-      src = `${base}videoseries?list=${encodeURIComponent(r.slice(5))}`;
-    } else if (r.includes('?list=')) {
-      const [v, l] = r.split('?list=');
-      src = `${base}${encodeURIComponent(v)}?list=${encodeURIComponent(l)}`;
-    } else {
-      src = base + encodeURIComponent(r);
-    }
+  static youtubeIframe({ id }) {
+    const src = `https://www.youtube-nocookie.com/embed/${id}`;
     return `
       <figure class="folio-embed folio-embed--youtube">
@@ -340,37 +184,7 @@
           return iframe || match;
         }
-        // Bare media file (…/clip.webm, …/song.mp3) → native player.
-        const media = this.mediaFileEmbed(url);
-        if (media) return media;
         return match;
       }
     );
-  }
-
-  /**
-   * Replace [[embed:<url>]] shortcodes with the platform iframe (YouTube, Spotify,
-   * SoundCloud, Apple Music, Bandcamp, Vimeo). The editor button inserts this
-   * shortcode; bare URL lines also embed automatically via autoembed().
-   * Unsupported/invalid URLs get a clean inline notice.
-   */
-  static embedMediaShortcodes(html) {
-    if (!html) return html;
-    return html.replace(/\[\[embed:([^\]]+)\]\]/gi, (match, rawUrl) => {
-      const url = rawUrl.trim().replace(/&amp;/g, '&');
-      const detected = this.detectProvider(url);
-      if (!detected) {
-        // Bare media file (…/clip.webm, …/song.mp3) → native player.
-        const media = this.mediaFileEmbed(url);
-        if (media) return media;
-        return `<div class="post-embed-missing"><em>Embed: niet-ondersteunde of ongeldige URL.</em></div>`;
-      }
-      // HERKEND maar niet te bouwen is geen reden om de shortcode zelf te
-      // tonen. Dat deed het wel, en dan leest een bezoeker "[[embed:https://...]]"
-      // op de pagina en denkt hij dat er iets stuk is. Onherkend gaf hierboven
-      // al een nette melding; herkend-maar-mislukt hoort dezelfde te geven,
-      // want voor de lezer is het hetzelfde geval.
-      return this.generateIframe(detected.provider, detected)
-        || `<div class="post-embed-missing"><em>Embed: niet-ondersteunde of ongeldige URL.</em></div>`;
-    });
   }
 
@@ -384,42 +198,17 @@
     return html.replace(/\[\[track:([A-Za-z0-9_-]+)\]\]/g, (match, id) => {
       const t = trackLookup(id);
-      if (!t) return match;
-      const titleH0 = this.escape(t.title || 'Untitled');
-      const artistH0 = this.escape(t.artist || '');
-      const creditBits0 = [this.escape(t.credit || ''), this.escape(t.license || '')].filter(Boolean).join(' · ');
-      // Link-only track (no audio file): no play button, but info + open-in links.
-      if (!t.url) {
-        const coverH0 = this.escape(t.cover || '');
-        const leader0 = coverH0
-          ? `<span class="pat-noplay pat-noplay--cover" style="background-image:url('${coverH0}')" aria-hidden="true"></span>`
-          : `<span class="pat-noplay" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg></span>`;
-        return `<div class="post-audio-track post-audio-track--static" id="track-${id}">
-  ${leader0}
-  <div class="pat-info">
-    <div class="pat-title">${titleH0}</div>
-    ${artistH0 ? `<div class="pat-artist">${artistH0}</div>` : ''}
-    ${creditBits0 ? `<div class="pat-credit">${creditBits0}</div>` : ''}
-  </div>
-  ${this.openInLinks(t)}
-</div>`;
-      }
+      if (!t || !t.url) return match;
       const trackJson = JSON.stringify({
-        id,
         url: t.url,
         title: t.title || 'Untitled',
         artist: t.artist || '',
         cover: t.cover || '',
-        credit: t.credit || '',
-        license: t.license || '',
       });
       const titleH = this.escape(t.title || 'Untitled');
       const artistH = this.escape(t.artist || '');
       const urlH = this.escape(t.url);
-      // Visible owner/license line below the track.
-      const creditBits = [this.escape(t.credit || ''), this.escape(t.license || '')].filter(Boolean).join(' · ');
       const dataAttr = trackJson
         .replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;');
-      // id="track-<id>" = anchor so the mini-player can scroll to this element.
-      return `<div class="post-audio-track" id="track-${id}" data-pcms-track-id="${id}" data-pcms-track-url="${urlH}" data-pcms-track='${dataAttr}'>
+      return `<div class="post-audio-track" data-pcms-track-url="${urlH}" data-pcms-track='${dataAttr}'>
   <button type="button" class="pat-play" aria-label="Play ${titleH}">
     <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 4l12 8-12 8z"/></svg>
@@ -428,7 +217,5 @@
     <div class="pat-title">${titleH}</div>
     ${artistH ? `<div class="pat-artist">${artistH}</div>` : ''}
-    ${creditBits ? `<div class="pat-credit">${creditBits}</div>` : ''}
   </div>
-  ${this.openInLinks(t)}
 </div>`;
     });
@@ -451,7 +238,5 @@
       // Stable DOM id for this rendering — used as data-pcms-album-id on tracks
       const albumDomId = 'album-' + Math.random().toString(36).slice(2, 10);
-      // Only playable tracks (with url) in the queue; link-only tracks appear
-      // in the list but not in the playback JSON.
-      const albumJson = JSON.stringify(album.tracks.filter((t) => t.url))
+      const albumJson = JSON.stringify(album.tracks)
         .replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;');
       const titleH = this.escape(album.title || name);
@@ -462,17 +247,6 @@
         const tTitle = this.escape(t.title || ('Track ' + (i + 1)));
         const tArtist = this.escape(t.artist || '');
-        // Link-only track: no play button, but track number + info + open-in links.
-        if (!t.url) {
-          return `    <li class="post-audio-track post-audio-track--static"${t.id ? ` id="track-${t.id}"` : ''}>
-      <span class="pat-track-num">${i + 1}.</span>
-      <div class="pat-info">
-        <div class="pat-title">${tTitle}</div>
-        ${tArtist && tArtist !== artistH ? `<div class="pat-artist">${tArtist}</div>` : ''}
-      </div>
-      ${this.openInLinks(t)}
-    </li>`;
-        }
         const tUrl = this.escape(t.url);
-        return `    <li class="post-audio-track"${t.id ? ` id="track-${t.id}" data-pcms-track-id="${t.id}"` : ''} data-pcms-track-url="${tUrl}" data-pcms-album-id="${albumDomId}">
+        return `    <li class="post-audio-track" data-pcms-track-url="${tUrl}" data-pcms-album-id="${albumDomId}">
       <button type="button" class="pat-play" aria-label="Play ${tTitle}">
         <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 4l12 8-12 8z"/></svg>
@@ -483,5 +257,4 @@
         ${tArtist && tArtist !== artistH ? `<div class="pat-artist">${tArtist}</div>` : ''}
       </div>
-      ${this.openInLinks(t)}
     </li>`;
       }).join('\n');
@@ -542,10 +315,6 @@
 
       const albumDomId = 'album-' + id;
-      // De soort zoals hij is opgeslagen. Stond hier als ternair met twee
-      // uitkomsten, en dan draagt een mixtape het jasje en het woord van een
-      // album -- dezelfde vorm die op vier andere plekken al misging.
-      const kind = SOORTEN.includes(pl.kind) ? pl.kind : 'album';
-      const KIND_LABEL = { album: '💿 Album', playlist: '📃 Playlist', mixtape: '📼 Mixtape' };
-      const kindLabel = KIND_LABEL[kind] || KIND_LABEL.album;
+      const kind = (pl.kind === 'playlist') ? 'playlist' : 'album';
+      const kindLabel = kind === 'playlist' ? '📃 Playlist' : '💿 Album';
       const titleH  = this.escape(pl.title || 'Naamloos');
       const artistH = this.escape(pl.artist || '');
@@ -554,17 +323,9 @@
       // Audio-player.js reads data-pcms-album for queue. Same shape as
       // embedAlbumShortcodes — keep both in sync.
-      // Only playable tracks in the queue; link-only tracks appear in the list
-      // but not in the playback JSON.
-      const tracksData = pl.tracks.filter(t => t.url).map(t => ({
-        id:     t.id,
+      const tracksData = pl.tracks.map(t => ({
         url:    t.url,
         title:  t.title,
         artist: t.artist || pl.artist || '',
         cover:  t.cover  || pl.cover  || '',
-        // De duur gaat mee omdat een BANDJE een lengte heeft. Zonder dit kan de
-        // speler alleen de gebufferde keten optellen, en dan las de teller
-        // 2:05 met een nummer geladen en 4:07 met drie -- een totaal dat
-        // meegroeit terwijl je luistert.
-        duration: Number(t.duration) || 0,
       }));
       const albumJson = JSON.stringify(tracksData)
@@ -583,5 +344,5 @@
       }
       const metaLine = this.escape(metaParts.join(' · '));
-      const firstUrl = this.escape((pl.tracks.find(t => t.url) || {}).url || '');
+      const firstUrl = this.escape(pl.tracks[0].url);
 
       // Track items — playlist-kind shows per-track cover thumbs, album-kind shows numbers
@@ -602,20 +363,6 @@
           : `<span class="pat-num">${i + 1}</span>`;
 
-        // Link-only track: no clickable play row (static div), but open-in links.
-        if (!t.url) {
-          return `    <li class="post-album-track-compact post-album-track-compact--static"${t.id ? ` id="track-${t.id}"` : ''}>
-      <div class="pat-row pat-static">
-        ${leader}
-        <span class="pat-meta">
-          <span class="pat-title">${tTitleH}</span>
-          ${showArtist ? `<span class="pat-artist">${tArtistH}</span>` : ''}
-        </span>
-        ${durHtml}
-      </div>
-      ${this.openInLinks(t)}
-    </li>`;
-        }
         const trackBase = String(t.url).split('?')[0];
-        return `    <li class="post-album-track-compact"${t.id ? ` id="track-${t.id}" data-pcms-track-id="${t.id}"` : ''}>
+        return `    <li class="post-album-track-compact">
       <button type="button" class="pat-row"
               data-pcms-track-url="${tUrl}"
@@ -630,19 +377,6 @@
         ${durHtml}
       </button>
-      ${this.openInLinks(t)}
     </li>`;
       }).join('\n');
-
-      // HET BANDJE HEEFT ZIJN EIGEN VORM. Een album toont een genummerde lijst
-      // waar je in kunt prikken; een cassette is juist het tegenovergestelde --
-      // je hoort wat er komt, in de volgorde waarin het is opgenomen. Alles
-      // hierboven (de wachtrij, de metaregel, de duur) is gedeeld; alleen de
-      // opmaak splitst hier.
-      if (kind === 'mixtape') {
-        return this.renderTape({
-          domId: albumDomId, id, titleH, artistH, coverH, metaLine, firstUrl,
-          albumJson, tracks: pl.tracks, isAdmin,
-        });
-      }
 
       return `<div class="post-album" id="${albumDomId}"
@@ -663,5 +397,5 @@
             data-pcms-track-url="${firstUrl}"
             data-pcms-album-id="${albumDomId}"
-            aria-label="Speel ${kind}">
+            aria-label="Speel ${kind === 'playlist' ? 'playlist' : 'album'}">
       ${coverH
         ? `<span class="post-album-cover" style="background-image:url('${coverH}')"></span>`
@@ -688,109 +422,4 @@
 
   /**
-   * Het bandje (Robins idee, 21-8).
-   *
-   * WAT HET ANDERS MAAKT DAN EEN ALBUM, en dat is de hele reden dat dit een
-   * eigen vorm heeft: bij een album prik je in een genummerde lijst en spring
-   * je naar nummer zeven. Op een cassette kan dat niet. Je spoelt vooruit of
-   * terug, en wat er komt hoor je in de volgorde waarin het is opgenomen. De
-   * lijst staat er dus wel -- je mag zien wat erop staat -- maar hij is geen
-   * knoppenrij.
-   *
-   * DE SPELER IS DE BESTAANDE SPELER. De knop hieronder draagt exact dezelfde
-   * data-attributen als de albumhoes (data-pcms-track-url + data-pcms-album-id),
-   * dus audio-player.js pakt hem op zonder dat hier iets nieuws bij komt. Vooruit
-   * en terug lopen via window.pcmsAudioPlayer.next()/prev(), en dat is meteen de
-   * reden dat spoelen per NUMMER gaat en niet per seconde: die speler denkt in
-   * een wachtrij, en een tweede speler ernaast bouwen om een band na te doen zou
-   * twee dingen tegelijk laten afspelen.
-   */
-  static renderTape({ domId, id, titleH, artistH, coverH, metaLine, firstUrl, albumJson, tracks, isAdmin }) {
-    // De nummers als tekst, niet als knoppen. Bewust geen data-pcms-track-url:
-    // een aanklikbaar nummer is precies wat een bandje niet heeft.
-    const lijst = tracks.map((t, i) => {
-      const tTitle = this.escape(t.title || ('Track ' + (i + 1)));
-      const dur = t.duration > 0
-        ? `${Math.floor(t.duration / 60)}:${String(t.duration % 60).padStart(2, '0')}`
-        : '—:—';
-      return `      <li class="tape-track" data-tape-index="${i}"><span class="tape-track-title">${tTitle}</span><span class="tape-track-dur">${dur}</span></li>`;
-    }).join('\n');
-
-    const spoel = (richting, label, pad) => `    <button type="button" class="tape-btn tape-btn--${richting}" data-tape-go="${richting}" aria-label="${label}">
-      <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">${pad}</svg>
-    </button>`;
-
-    return `<div class="post-tape" id="${domId}"
-     data-pcms-album='${albumJson}'
-     data-pcms-album-title="${titleH}"
-     data-pcms-album-kind="mixtape"
-     data-pcms-playlist-id="${this.escape(id)}">
-${isAdmin ? `  <div class="post-album-actions" role="group" aria-label="Mixtape beheren">
-    <a class="post-album-action" href="/admin/playlists?edit=${this.escape(id)}" title="Bewerk mixtape" aria-label="Bewerk mixtape">
-      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>
-    </a>
-  </div>
-` : ''}  <div class="tape-shell">
-    <svg class="tape-svg" viewBox="0 0 314 200" role="img" aria-label="Cassette" preserveAspectRatio="xMidYMid meet">
-      <defs>
-        <clipPath id="tapewin-${domId}"><rect x="72" y="96" width="170" height="62" rx="6"/></clipPath>
-      </defs>
-      <!-- de behuizing -->
-      <rect class="tape-body" x="3" y="3" width="308" height="194" rx="9"/>
-      <rect class="tape-body-inner" x="12" y="12" width="290" height="176" rx="6"/>
-      <!-- het labelvlak: hier komt de HTML-tekst overheen te staan -->
-      <rect class="tape-labelplate" x="24" y="22" width="266" height="62" rx="3"/>
-      <!-- het venster waardoor je de band ziet lopen -->
-      <rect class="tape-glass" x="72" y="96" width="170" height="62" rx="6"/>
-      <g clip-path="url(#tapewin-${domId})">
-        <!-- de bandpakketten om de spoelen; links loopt vol terwijl rechts leegloopt -->
-        <circle class="tape-pack tape-pack--left" cx="112" cy="127" r="30"/>
-        <circle class="tape-pack tape-pack--right" cx="202" cy="127" r="22"/>
-        <rect class="tape-ribbon" x="112" y="150" width="90" height="3"/>
-      </g>
-      <!-- de spoelen zelf: deze twee draaien -->
-      <g class="tape-reel tape-reel--left" style="transform-origin:112px 127px">
-        <circle class="tape-hub" cx="112" cy="127" r="15"/>
-        ${[0, 60, 120, 180, 240, 300].map((a) => `<rect class="tape-tooth" x="109.5" y="112" width="5" height="9" rx="1" transform="rotate(${a} 112 127)"/>`).join('')}
-      </g>
-      <g class="tape-reel tape-reel--right" style="transform-origin:202px 127px">
-        <circle class="tape-hub" cx="202" cy="127" r="15"/>
-        ${[0, 60, 120, 180, 240, 300].map((a) => `<rect class="tape-tooth" x="199.5" y="112" width="5" height="9" rx="1" transform="rotate(${a} 202 127)"/>`).join('')}
-      </g>
-      <!-- de schroefjes in de hoeken, en de openingen voor de kop onderin -->
-      <circle class="tape-screw" cx="22" cy="22" r="3.5"/>
-      <circle class="tape-screw" cx="292" cy="22" r="3.5"/>
-      <circle class="tape-screw" cx="22" cy="178" r="3.5"/>
-      <circle class="tape-screw" cx="292" cy="178" r="3.5"/>
-      <rect class="tape-slot" x="128" y="168" width="26" height="14" rx="2"/>
-      <rect class="tape-slot" x="160" y="168" width="26" height="14" rx="2"/>
-      <rect class="tape-slot tape-slot--capstan" x="98" y="170" width="10" height="12" rx="2"/>
-      <rect class="tape-slot tape-slot--capstan" x="206" y="170" width="10" height="12" rx="2"/>
-    </svg>
-    <div class="tape-label">
-      <p class="tape-kind">Mixtape</p>
-      <h3 class="tape-title">${titleH}</h3>
-      ${artistH ? `<p class="tape-artist">${artistH}</p>` : ''}
-      <p class="tape-meta">${metaLine}</p>
-    </div>
-  </div>
-  <div class="tape-controls" role="group" aria-label="Bandje bedienen">
-${spoel('back', 'Terugspoelen', '<path d="M11 12l9-7v14zM2 12l9-7v14z"/>')}
-    <button type="button" class="tape-btn tape-btn--play"
-            data-pcms-track-url="${firstUrl}"
-            data-pcms-album-id="${domId}"
-            data-tape-play
-            aria-label="Afspelen">
-      <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 4l12 8-12 8z"/></svg>
-    </button>
-${spoel('fwd', 'Vooruitspoelen', '<path d="M13 12L4 5v14zM22 12l-9-7v14z"/>')}
-  </div>
-  <p class="tape-now" data-tape-now aria-live="polite"></p>
-  <ol class="tape-tracks">
-${lijst}
-  </ol>
-</div>`;
-  }
-
-  /**
    * Human-readable label for a provider slug. Used by external-link buttons.
    */
@@ -834,5 +463,5 @@
    * Replace [[link:url]] or [[link:url|Custom Label]] shortcodes with a
    * branded "Open in <Platform>" anchor (no iframe). Opens in new tab.
-   * Per Robin's v9: "External link, click = open platform (target _blank)".
+   * Per Robin's v9: "Externe link, klik = open platform (target _blank)".
    */
   static embedExternalLinkShortcodes(html) {
Index: src/services/AudioStreamService.js
===================================================================
--- src/services/AudioStreamService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/services/AudioStreamService.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,33 +1,112 @@
 /**
- * AudioStreamService — builds URLs for the audio streaming route.
+ * AudioStreamService — Signed audio streaming, v9-style.
  *
- * ┌─ ANTI-THEFT MODEL (Spotify-flavoured, step 1 — 2026-05-20) ─────────────┐
- * │ audioUrl() returns a plain /audio/stream/<filename> path. There is NO   │
- * │ signed/expiring token in the URL — that earlier design baked a single   │
- * │ 10-min deadline into a whole queue at render time, so later tracks'     │
- * │ tokens expired mid-session and the player looped "next" forever.        │
- * │                                                                          │
- * │ Protection now lives in two NON-expiring layers, so it can't cause that │
- * │ failure again:                                                           │
- * │   1. Client (audio-player.js) fetch()es the bytes and plays from a      │
- * │      blob: object URL — no shareable link, no "save audio as".          │
- * │   2. Server (routes/audio.js) gates /audio/stream to same-origin        │
- * │      browser fetches — blocks address-bar paste, hotlinks, curl/yt-dlp. │
- * │                                                                          │
- * │ FUTURE STEPS (deliberate, tested one at a time):                         │
- * │   - step 2: MSE chunked/progressive streaming (true Spotify feel)        │
- * │   - step 3: per-session short-lived token in a header, minted JIT        │
- * │   - step 4: light byte obfuscation (XOR/key) on the wire                 │
- * └──────────────────────────────────────────────────────────────────────┘
+ * The src of <audio> is /audio/stream/:filename?t=HMAC&exp=TIMESTAMP.
+ * HMAC = SHA256(filename|exp|AUDIO_SECRET).
+ *
+ * Defeats hotlinking, scrapers, casual URL sharing — not state actors.
+ * Token TTL: 10 minutes (long enough for a track, short enough that a
+ * shared link expires before anyone can use it).
+ *
+ * AUDIO_SECRET comes from env. If missing on first boot, generate one
+ * and persist to storage/.audio-secret so it survives restarts.
  */
 
-/**
- * Build the public stream URL for an audio filename.
- * Returns null for a falsy filename so callers can guard playability.
- */
-export function audioUrl(filename) {
-  if (!filename) return null;
-  return `/audio/stream/${encodeURIComponent(filename)}`;
+import crypto from 'crypto';
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const SECRET_FILE = path.join(__dirname, '..', '..', 'storage', '.audio-secret');
+
+export const TOKEN_TTL_SECONDS = 600;
+
+let cachedSecret = null;
+
+function loadOrGenerateSecret() {
+  if (cachedSecret) return cachedSecret;
+
+  // 1. Env wins
+  if (process.env.AUDIO_SECRET && process.env.AUDIO_SECRET.length >= 32) {
+    cachedSecret = process.env.AUDIO_SECRET;
+    return cachedSecret;
+  }
+
+  // 2. Persisted file
+  try {
+    const fromDisk = fs.readFileSync(SECRET_FILE, 'utf-8').trim();
+    if (fromDisk.length >= 32) {
+      cachedSecret = fromDisk;
+      return cachedSecret;
+    }
+  } catch (e) { /* file missing — generate */ }
+
+  // 3. Generate + persist
+  const generated = crypto.randomBytes(32).toString('hex');
+  try {
+    fs.mkdirSync(path.dirname(SECRET_FILE), { recursive: true });
+    fs.writeFileSync(SECRET_FILE, generated, { mode: 0o600 });
+    console.log('AudioStreamService: generated new audio secret at', SECRET_FILE);
+  } catch (e) {
+    console.error('AudioStreamService: could not persist audio secret:', e.message);
+  }
+  cachedSecret = generated;
+  return cachedSecret;
 }
 
-export default { audioUrl };
+function makeHmac(filename, exp) {
+  const secret = loadOrGenerateSecret();
+  return crypto
+    .createHmac('sha256', secret)
+    .update(`${filename}|${exp}`)
+    .digest('hex');
+}
+
+/**
+ * Sign a filename → returns { url, exp, t } so callers can build the URL.
+ * The full URL is /audio/stream/<filename>?t=<t>&exp=<exp>.
+ */
+export function signUrl(filename, ttlSeconds = TOKEN_TTL_SECONDS) {
+  const exp = Math.floor(Date.now() / 1000) + ttlSeconds;
+  const t = makeHmac(filename, exp);
+  const safe = encodeURIComponent(filename);
+  return {
+    url: `/audio/stream/${safe}?t=${t}&exp=${exp}`,
+    exp,
+    t,
+  };
+}
+
+/**
+ * Verify a token for a filename. Returns true iff exp is in the future
+ * AND the HMAC matches.
+ */
+export function verifyToken(filename, t, exp) {
+  if (!filename || !t || !exp) return false;
+  const expNum = Number(exp);
+  if (!Number.isFinite(expNum)) return false;
+  if (expNum < Math.floor(Date.now() / 1000)) return false;
+
+  const expected = makeHmac(filename, expNum);
+  // timingSafeEqual requires equal-length buffers
+  try {
+    const a = Buffer.from(t, 'hex');
+    const b = Buffer.from(expected, 'hex');
+    if (a.length !== b.length) return false;
+    return crypto.timingSafeEqual(a, b);
+  } catch (e) {
+    return false;
+  }
+}
+
+/**
+ * Force-rotate the secret. Invalidates all outstanding tokens.
+ */
+export function rotateSecret() {
+  cachedSecret = null;
+  try { fs.unlinkSync(SECRET_FILE); } catch (e) {}
+  return loadOrGenerateSecret();
+}
+
+export default { signUrl, verifyToken, rotateSecret, TOKEN_TTL_SECONDS };
Index: src/services/AudioTranscoder.js
===================================================================
--- src/services/AudioTranscoder.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/services/AudioTranscoder.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -90,8 +90,6 @@
   const tmpPath = path.join(outputDir, `${outputBaseName}.transcoding-${process.pid}.mp3`);
 
-  let durationSec = null;
   try {
-    const r = await runFfmpeg({ inputPath, tmpPath, tags });
-    durationSec = r && r.durationSec != null ? r.durationSec : null;
+    await runFfmpeg({ inputPath, tmpPath, tags });
 
     // Verify the output is not zero bytes — ffmpeg sometimes "succeeds" but
@@ -126,5 +124,4 @@
       size: outStat.size,
       mimeType: 'audio/mpeg',
-      durationSec,   // whole seconds from ffmpeg's codecData (null if unknown)
     };
 
@@ -138,43 +135,4 @@
 
 /**
- * Rewrite the ID3 tags of an EXISTING mp3 without re-encoding (`-c copy`).
- * Used when editing track metadata (title/artist/album/credit/license) so that
- * ownership info travels with the file on download.
- * ffmpeg cannot edit in-place → write to tmp and atomically rename back.
- */
-export async function retagMp3({ filePath, tags = {} }) {
-  if (!filePath) throw new Error('retagMp3: filePath required');
-  await stat(filePath); // throws if file is missing
-  const dir = path.dirname(filePath);
-  const base = path.basename(filePath, path.extname(filePath));
-  const tmpPath = path.join(dir, `${base}.retag-${process.pid}.mp3`);
-  try {
-    await new Promise((resolve, reject) => {
-      const cmd = ffmpeg(filePath)
-        .audioCodec('copy')        // no re-encode → fast, no quality loss
-        .format('mp3')
-        .outputOptions('-id3v2_version', '3')
-        .outputOptions('-map_metadata', '-1')
-        .outputOptions('-vn');
-      if (tags.title)     cmd.outputOptions('-metadata', `title=${tags.title}`);
-      if (tags.artist)    cmd.outputOptions('-metadata', `artist=${tags.artist}`);
-      if (tags.album)     cmd.outputOptions('-metadata', `album=${tags.album}`);
-      if (tags.copyright) cmd.outputOptions('-metadata', `copyright=${tags.copyright}`);
-      if (tags.comment)   cmd.outputOptions('-metadata', `comment=${tags.comment}`);
-      cmd.on('error', (err, so, se) => reject(new Error(((err && err.message) || 'ffmpeg') + (se ? ' | ' + se : ''))))
-         .on('end', () => resolve())
-         .save(tmpPath);
-    });
-    const s = await stat(tmpPath);
-    if (s.size === 0) throw new Error('retag output is empty');
-    await rename(tmpPath, filePath);
-    return { filePath, size: s.size };
-  } catch (err) {
-    try { await unlink(tmpPath); } catch { /* tmp may not exist */ }
-    throw err;
-  }
-}
-
-/**
  * Run a single ffmpeg pass: input -> tmp output.
  * Returns a promise that resolves when ffmpeg exits cleanly, rejects otherwise.
@@ -182,5 +140,4 @@
 function runFfmpeg({ inputPath, tmpPath, tags }) {
   return new Promise((resolve, reject) => {
-    let durationSec = null;
     const cmd = ffmpeg(inputPath)
       .audioCodec('libmp3lame')
@@ -206,14 +163,9 @@
     // breaks any value containing a space (e.g. "Test Artist" gets parsed
     // as a separate output filename).
-    if (tags.title)     cmd.outputOptions('-metadata', `title=${tags.title}`);
-    if (tags.artist)    cmd.outputOptions('-metadata', `artist=${tags.artist}`);
-    if (tags.album)     cmd.outputOptions('-metadata', `album=${tags.album}`);
-    if (tags.copyright) cmd.outputOptions('-metadata', `copyright=${tags.copyright}`); // ID3 TCOP — owner/credit
-    if (tags.comment)   cmd.outputOptions('-metadata', `comment=${tags.comment}`);     // ID3 COMM — license
+    if (tags.title)  cmd.outputOptions('-metadata', `title=${tags.title}`);
+    if (tags.artist) cmd.outputOptions('-metadata', `artist=${tags.artist}`);
+    if (tags.album)  cmd.outputOptions('-metadata', `album=${tags.album}`);
 
     cmd
-      // codecData gives the INPUT duration as "HH:MM:SS.xx" — this lets us
-      // determine the track length automatically without a separate ffprobe binary.
-      .on('codecData', (data) => { durationSec = parseHmsToSeconds(data && data.duration); })
       .on('error', (err, stdout, stderr) => {
         // ffmpeg's stderr is the most useful diagnostic. fluent-ffmpeg's
@@ -224,44 +176,8 @@
         reject(new Error(`Transcode failed: ${reason}${tail ? '\n' + tail : ''}`));
       })
-      .on('end', () => resolve({ durationSec }))
+      .on('end', () => resolve())
       .save(tmpPath);
   });
 }
 
-/**
- * Parse an ffmpeg duration string "HH:MM:SS.xx" to whole seconds. Returns null
- * for "N/A" or an unexpected format.
- */
-function parseHmsToSeconds(hms) {
-  if (!hms || typeof hms !== 'string') return null;
-  const m = hms.match(/^(\d+):(\d{2}):(\d{2})(?:\.(\d+))?$/);
-  if (!m) return null;
-  const sec = (+m[1]) * 3600 + (+m[2]) * 60 + (+m[3]) + (m[4] ? Number('0.' + m[4]) : 0);
-  return Number.isFinite(sec) ? Math.round(sec) : null;
-}
-
-/**
- * Read the duration (whole seconds) of an audio file WITHOUT transcoding.
- * Starts an ffmpeg pass and reads only the codecData event (duration), then
- * kills the process immediately — fast and without a separate ffprobe binary
- * (ffmpeg-static ships only ffmpeg). Intended for the backfill script.
- * @returns {Promise<number|null>}
- */
-export function probeDuration(filePath) {
-  return new Promise((resolve) => {
-    let durationSec = null, done = false;
-    const finish = () => { if (!done) { done = true; resolve(durationSec); } };
-    const cmd = ffmpeg(filePath)
-      .on('codecData', (data) => {
-        durationSec = parseHmsToSeconds(data && data.duration);
-        try { cmd.kill('SIGKILL'); } catch { /* already done */ }
-        finish();
-      })
-      .on('error', finish)
-      .on('end', finish)
-      .format('null')
-      .save(process.platform === 'win32' ? 'NUL' : '/dev/null');
-  });
-}
-
-export default { transcodeToMp3, probeDuration };
+export default { transcodeToMp3 };
Index: src/services/BlocklistService.js
===================================================================
--- src/services/BlocklistService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,103 +1,0 @@
-/**
- * The instance blocklist (ap_blocks): actors and whole domains a site has
- * blocked. Lives NEXT TO the guardianship module, not inside it, because it
- * is shared: Klonkt's own Block tab uses it, and Shaer's "in Orbit" reads it
- * as the source of truth (AP §5.6 blocked collection, owner-only).
- *
- * Extracted from ActivityPubService (guardianship refactor); behavior is
- * unchanged. ActivityPubService re-exports these under the old names so
- * existing callers keep working.
- */
-import db from '../config/database.js';
-
-let _insBl, _delBl, _listBl;
-function blStmts() {
-  if (!_insBl) {
-    _insBl = db.prepare('INSERT OR IGNORE INTO ap_blocks (slug, target, kind, label, created_at) VALUES (?,?,?,?,CURRENT_TIMESTAMP)');
-    _delBl = db.prepare('DELETE FROM ap_blocks WHERE slug = ? AND target = ?');
-    _listBl = db.prepare('SELECT * FROM ap_blocks WHERE slug = ? ORDER BY created_at DESC');
-  }
-  return { ins: _insBl, del: _delBl, list: _listBl };
-}
-
-export function listBlocks(slug) { return blStmts().list.all(slug); }
-
-// True if an actor (or its whole domain) is blocked anywhere on this instance.
-export function isBlockedAny(actorUri) {
-  if (!actorUri) return false;
-  let domain = ''; try { domain = new URL(actorUri).host; } catch { /* ignore */ }
-  try { return !!db.prepare("SELECT 1 FROM ap_blocks WHERE (kind='actor' AND target=?) OR (kind='domain' AND target=?) LIMIT 1").get(actorUri, domain); }
-  catch { return false; }
-}
-
-function purgeBlocked(kind, target) {
-  try {
-    if (kind === 'domain') {
-      // Exact host match (a URL LIKE over-/under-matches: it misses bare-domain or :port
-      // actor URIs and can catch look-alikes). Filter by parsed host, same as isBlockedAny.
-      const purge = (table, col) => {
-        let rows = [];
-        try { rows = db.prepare(`SELECT DISTINCT ${col} AS u FROM ${table} WHERE ${col} IS NOT NULL AND ${col} != ''`).all(); } catch { return; }
-        const del = db.prepare(`DELETE FROM ${table} WHERE ${col} = ?`);
-        for (const r of rows) { let h = ''; try { h = new URL(r.u).host; } catch { /* skip */ } if (h === target) { try { del.run(r.u); } catch { /* ignore */ } } }
-      };
-      purge('ap_interactions', 'actor_uri');
-      purge('ap_timeline', 'author_uri');
-      purge('ap_followers', 'actor_uri');
-    } else {
-      db.prepare('DELETE FROM ap_interactions WHERE actor_uri = ?').run(target);
-      db.prepare('DELETE FROM ap_timeline WHERE author_uri = ?').run(target);
-      db.prepare('DELETE FROM ap_followers WHERE actor_uri = ?').run(target);
-    }
-  } catch { /* best-effort */ }
-}
-
-// Block an actor (@handle or actor URL) or a whole domain; purges their content.
-// `resolveHandle` (async handle → actor URL) is injected by the caller so this
-// service needs nothing from ActivityPubService (no circular import).
-/**
- * Een blokkade is pas een blokkade als de ander het merkt (Robin, 21-8).
- *
- * Tot vandaag bleef hij binnenshuis: rij in ap_blocks, inhoud opruimen, volger
- * eruit -- en verder niets. De andere kant volgde je dan nog steeds in zijn
- * eigen boeken en bleef je publieke outbox lezen. Precies wat de hub deed:
- * kanaal en berichten stonden er gewoon nog. Vandaar dat we het nu ook
- * VERSTUREN, zoals Mastodon dat doet: een Block naar de inbox van wie je
- * blokkeert, en bij opheffen een Undo(Block) zodat de weg terug openligt.
- *
- * Alleen voor een actor-blokkade: een heel domein heeft geen inbox om aan te
- * schrijven. En bezorgen mag nooit de blokkade zelf tegenhouden -- die staat
- * al vast in de database voordat we ook maar iets proberen te versturen.
- */
-async function meldBlokkade(site, target, kind, bezorg, undo = false) {
-  if (kind !== 'actor' || typeof bezorg !== 'function') return;
-  try { await bezorg(site, target, undo); } catch { /* de blokkade staat, de melding is een gunst */ }
-}
-
-export async function blockTarget(site, input, resolveHandle, bezorg) {
-  const raw = String(input || '').trim();
-  if (!site || !site.slug || !raw) return { error: 'empty' };
-  let kind, target, label;
-  if (/^https?:\/\//i.test(raw)) { kind = 'actor'; target = raw; label = raw; }
-  else if (raw.includes('@')) {
-    const actorUrl = resolveHandle ? await resolveHandle(raw) : null;
-    if (!actorUrl) return { error: 'not_found' };
-    kind = 'actor'; target = actorUrl; label = raw.startsWith('@') ? raw : ('@' + raw);
-  } else { kind = 'domain'; target = raw.toLowerCase(); label = raw.toLowerCase(); }
-  blStmts().ins.run(site.slug, target, kind, label);
-  purgeBlocked(kind, target);
-  console.log('[AP] block', site.slug, kind, target);
-  await meldBlokkade(site, target, kind, bezorg);
-  return { ok: true, label };
-}
-
-export async function unblock(site, target, bezorg) {
-  const rij = blStmts().list.all(site.slug).find((b) => b.target === target);
-  blStmts().del.run(site.slug, target);
-  // Undo(Block) zodat de ander weet dat de deur weer open is; zonder dit blijft
-  // hij bij zichzelf geblokkeerd staan en komt hij nooit terug.
-  await meldBlokkade(site, target, (rij && rij.kind) || 'actor', bezorg, true);
-  return { ok: true };
-}
-
-export default { listBlocks, isBlockedAny, blockTarget, unblock };
Index: src/services/CryptoBox.js
===================================================================
--- src/services/CryptoBox.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,102 +1,0 @@
-// Symmetric encryption for secrets at rest (paid posts: the site owner's
-// Patreon creator token, klonkt-demo-aki slice 1). AES-256-GCM with a key
-// derived from a secret, so a database dump alone leaks nothing usable.
-// Format: base64(iv) : base64(tag) : base64(ciphertext).
-//
-// The secret is resolved in this order so nobody has to edit the env:
-//   1. PAID_SECRET (env) — authoritative; a self-hoster who set it by hand
-//      (or Bart, who already did) keeps working unchanged.
-//   2. a persisted key file next to the database, auto-generated on first use
-//      (0600). This is what "first run" and "existing users after an update"
-//      get automatically.
-// The key lives OUTSIDE the sqlite DB on purpose: encrypting the Patreon
-// secrets is pointless if the key sits in the same file a DB dump would leak.
-import crypto from 'crypto';
-import fs from 'fs';
-import path from 'path';
-import { fileURLToPath } from 'url';
-
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
-
-// The key file sits in the same directory as the database.
-function keyFilePath() {
-  const dbPath = process.env.DATABASE_PATH || path.join(__dirname, '../../storage/database.sqlite');
-  const dir = dbPath === ':memory:' ? path.join(__dirname, '../../storage') : path.dirname(dbPath);
-  return path.join(dir, '.paid-secret');
-}
-
-// Read the persisted key, generating + writing it (0600) the first time.
-function fileSecret() {
-  const file = keyFilePath();
-  try {
-    const existing = fs.readFileSync(file, 'utf8').trim();
-    if (existing.length >= 16) return existing;
-  } catch { /* not created yet */ }
-  const generated = crypto.randomBytes(32).toString('base64');
-  fs.mkdirSync(path.dirname(file), { recursive: true });
-  fs.writeFileSync(file, generated, { mode: 0o600 });
-  try { fs.chmodSync(file, 0o600); } catch { /* non-POSIX fs */ }
-  return generated;
-}
-
-// env wins; otherwise the auto-generated file. Never returns an ephemeral key:
-// if the file can't be persisted, fileSecret throws and the feature stays gated
-// (cryptoBoxReady false) rather than encrypting with a key lost on restart.
-function resolveSecret() {
-  const env = process.env.PAID_SECRET;
-  if (env && String(env).length >= 16) return String(env);
-  return fileSecret();
-}
-
-let _key = null;
-function key() {
-  if (_key) return _key;
-  _key = crypto.scryptSync(resolveSecret(), 'klonkt-paid', 32);
-  return _key;
-}
-
-// True when a key is configured, so callers can gate the feature instead of throwing.
-export function cryptoBoxReady() {
-  try { key(); return true; } catch { return false; }
-}
-
-export function encrypt(plaintext) {
-  if (plaintext == null) return null;
-  const iv = crypto.randomBytes(12);
-  const cipher = crypto.createCipheriv('aes-256-gcm', key(), iv);
-  const ct = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]);
-  const tag = cipher.getAuthTag();
-  return `${iv.toString('base64')}:${tag.toString('base64')}:${ct.toString('base64')}`;
-}
-
-export function decrypt(blob) {
-  if (blob == null || blob === '') return null;
-  const parts = String(blob).split(':');
-  if (parts.length !== 3) throw new Error('malformed ciphertext');
-  const [iv, tag, ct] = parts.map((p) => Buffer.from(p, 'base64'));
-  const decipher = crypto.createDecipheriv('aes-256-gcm', key(), iv);
-  decipher.setAuthTag(tag);
-  return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
-}
-
-// The stateless signed blob reused for the OAuth state and the WebAuthn
-// challenge (design doc "cookie-less trick"): HMAC over a short-lived payload,
-// so no server session is needed to bind pending state to a browser.
-export function signBlob(payload, ttlSeconds = 600) {
-  const body = { ...payload, exp: Math.floor(Date.now() / 1000) + ttlSeconds, nonce: crypto.randomBytes(8).toString('hex') };
-  const b = Buffer.from(JSON.stringify(body)).toString('base64url');
-  const tag = crypto.createHmac('sha256', key()).update(b).digest('base64url');
-  return `${b}.${tag}`;
-}
-
-// Returns the payload if valid and unexpired, else null. Constant-time tag check.
-export function verifyBlob(token) {
-  const [b, tag] = String(token || '').split('.');
-  if (!b || !tag) return null;
-  const expected = crypto.createHmac('sha256', key()).update(b).digest('base64url');
-  const a = Buffer.from(tag); const e = Buffer.from(expected);
-  if (a.length !== e.length || !crypto.timingSafeEqual(a, e)) return null;
-  let payload; try { payload = JSON.parse(Buffer.from(b, 'base64url').toString('utf8')); } catch { return null; }
-  if (!payload || (payload.exp && payload.exp * 1000 < Date.now())) return null;
-  return payload;
-}
Index: src/services/EmbedResolver.js
===================================================================
--- src/services/EmbedResolver.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,346 +1,0 @@
-// One pipeline for everything you can drop a URL of into a post, and one visual
-// result. What differs is not what a reader sees but what FEDERATES.
-//
-// Resolution order (Robins besluit, shaer-277):
-//   1. ActivityPub object  → the FEP path. A quote of a fediverse object carries
-//      real semantics: FEP-044f `quote` + an FEP-e232 Link tag, the quoted
-//      author gets addressed, and the permission model applies. Never resolved
-//      over oEmbed, because oEmbed has none of that.
-//   2. oEmbed, else OpenGraph → one page fetch, and no list of providers to
-//      maintain. Whether an embed may be shown at all is a guardian decision
-//      (the gate), not a question of which host it came from.
-//   3. Otherwise           → a plain link.
-//
-// Everything returns the SAME normalised shape, so one renderer draws them all
-// (the quote card). Pure except for the two injected fetchers, so the ordering
-// logic is unit-testable without a network.
-
-const OEMBED_LINK = /<link\b[^>]*>/gi;
-
-/** Pull the oEmbed endpoint out of a page's <link rel="alternate"> tags. */
-export function findOEmbedEndpoint(html) {
-  if (!html || typeof html !== 'string') return null;
-  for (const tag of html.match(OEMBED_LINK) || []) {
-    const type = (tag.match(/\btype\s*=\s*["']([^"']+)["']/i) || [])[1] || '';
-    if (!/application\/(json|xml)\+oembed/i.test(type)) continue;
-    const rel = (tag.match(/\brel\s*=\s*["']([^"']+)["']/i) || [])[1] || '';
-    if (rel && !/alternate/i.test(rel)) continue;
-    const href = (tag.match(/\bhref\s*=\s*["']([^"']+)["']/i) || [])[1];
-    // JSON only: we do not parse the XML flavour.
-    if (href && /json/i.test(type)) return decodeEntities(href);
-  }
-  return null;
-}
-
-function decodeEntities(s) {
-  return String(s).replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#39;/g, "'");
-}
-
-/**
- * OpenGraph, the one that actually carries link previews on the open web.
- * oEmbed is the richer protocol but most sites simply do not implement it;
- * og:image / og:title is what Mastodon and everyone else reads, so it is the
- * fallback that makes thumbnails appear at all. Same page fetch as the oEmbed
- * discovery, so it costs nothing extra.
- */
-export function findOpenGraph(html) {
-  if (!html || typeof html !== 'string') return null;
-  const meta = {};
-  for (const tag of html.match(/<meta\b[^>]*>/gi) || []) {
-    const key = (tag.match(/\b(?:property|name)\s*=\s*["']([^"']+)["']/i) || [])[1];
-    if (!key) continue;
-    const k = key.toLowerCase();
-    if (!/^(og:image|og:title|og:site_name|og:description|twitter:image|twitter:title)$/.test(k)) continue;
-    const val = (tag.match(/\bcontent\s*=\s*["']([^"']*)["']/i) || [])[1];
-    if (val && !meta[k]) meta[k] = decodeEntities(val);
-  }
-  const image = meta['og:image'] || meta['twitter:image'];
-  const title = meta['og:title'] || meta['twitter:title'];
-  if (!image && !title) return null;
-  return { image: image && /^https?:\/\//i.test(image) ? image : null, title: title || null, site: meta['og:site_name'] || null };
-}
-
-/**
- * Match a URL against the public oEmbed provider registry (oembed.com).
- *
- * Discovery through the page is the pure way, but it only works when the page
- * hands you its <link rel=oembed>, and big platforms do not always do that: from
- * a datacentre IP, YouTube serves a stripped page with no oEmbed link and no
- * OpenGraph at all, while its oEmbed API answers perfectly. The registry closes
- * that gap without us keeping a list of hosts: it is published and maintained by
- * oembed.com, we only read it.
- *
- * Pure, so the pattern matching is testable without a network.
- */
-export function matchProviderEndpoint(url, providers) {
-  if (!Array.isArray(providers)) return null;
-  let host = '';
-  try { host = new URL(url).host.replace(/^www\./, ''); } catch { return null; }
-  const toRe = (scheme) => new RegExp('^' + String(scheme)
-    .replace(/[.+?^${}()|[\]\\]/g, '\\$&')
-    .replace(/\*/g, '.*') + '$', 'i');
-  for (const p of providers) {
-    for (const ep of (p.endpoints || [])) {
-      const target = typeof ep.url === 'string' ? ep.url.replace('{format}', 'json') : null;
-      if (!target) continue;
-      for (const scheme of (ep.schemes || [])) {
-        if (toRe(scheme).test(url)) return target;
-      }
-      // No schemes listed: fall back to the provider's own host.
-      if (!ep.schemes || !ep.schemes.length) {
-        let phost = '';
-        try { phost = new URL(p.provider_url).host.replace(/^www\./, ''); } catch { /* skip */ }
-        if (phost && (host === phost || host.endsWith('.' + phost))) return target;
-      }
-    }
-  }
-  return null;
-}
-
-/** Add the url + json format to an oEmbed endpoint. */
-export function oembedRequestUrl(endpoint, url) {
-  const sep = endpoint.includes('?') ? '&' : '?';
-  return `${endpoint}${sep}format=json&url=${encodeURIComponent(url)}`;
-}
-
-/** Is this JSON an ActivityPub object we can quote? */
-export function looksLikeAPObject(doc) {
-  if (!doc || typeof doc !== 'object') return false;
-  const t = Array.isArray(doc.type) ? doc.type[0] : doc.type;
-  if (typeof t !== 'string') return false;
-  // Quotable content, not an actor and not an activity.
-  return ['Note', 'Article', 'Page', 'Video', 'Audio', 'Image', 'Question', 'Event'].includes(t)
-    && typeof doc.id === 'string';
-}
-
-/** An oEmbed payload → the shared card shape. */
-export function fromOEmbed(url, o) {
-  if (!o || typeof o !== 'object') return null;
-  const media = [];
-  if (o.thumbnail_url) media.push({ url: String(o.thumbnail_url), type: 'image/*' });
-  return {
-    kind: 'oembed',
-    url: typeof o.url === 'string' && /^https?:/i.test(o.url) ? o.url : url,
-    title: o.title ? String(o.title) : null,
-    author: (o.author_name || o.provider_name) ? {
-      name: o.author_name ? String(o.author_name) : String(o.provider_name),
-      handle: o.provider_name ? String(o.provider_name) : null,
-      icon: null,
-    } : null,
-    // `html` is the provider's own iframe. Kept separate from the card body so
-    // a caller can decide to frame it or to fall back to the thumbnail; it is
-    // never merged into sanitized note content.
-    html: typeof o.html === 'string' ? o.html : null,
-    provider: o.provider_name ? String(o.provider_name) : null,
-    media,
-  };
-}
-
-/** An AP object → the same shape a resolved quote already uses. */
-export function fromAPObject(url, doc, author) {
-  const attributed = typeof doc.attributedTo === 'string' ? doc.attributedTo
-    : (doc.attributedTo && typeof doc.attributedTo.id === 'string' ? doc.attributedTo.id : null);
-  return {
-    kind: 'ap',
-    url: (typeof doc.url === 'string' && doc.url) || doc.id || url,
-    id: doc.id,
-    attributedTo: attributed,
-    title: doc.name ? String(doc.name) : null,
-    content: typeof doc.content === 'string' ? doc.content : '',
-    published: doc.published || null,
-    author: author || null,
-    media: [],
-  };
-}
-
-/**
- * Resolve one URL to the shared card shape.
- *
- * @param {string} url
- * @param {object} io
- *   - getAP(url)      → the AP JSON (Accept: application/activity+json) or null
- *   - getPage(url)    → the HTML body or null
- *   - getJSON(url)    → arbitrary JSON (the oEmbed endpoint) or null
- *   - actorOf(uri)    → { name, handle, icon } for the AP author, or null
- *   - provider(url)   → the known-provider hit (AudioEmbedService.detectProvider)
- */
-export async function resolveEmbed(url, io = {}) {
-  if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) return null;
-
-  // 1. ActivityPub first: it is the only path that carries quote semantics.
-  if (io.getAP) {
-    const doc = await io.getAP(url).catch(() => null);
-    if (looksLikeAPObject(doc)) {
-      const attributed = typeof doc.attributedTo === 'string' ? doc.attributedTo
-        : (doc.attributedTo && doc.attributedTo.id);
-      const author = (attributed && io.actorOf) ? await io.actorOf(attributed).catch(() => null) : null;
-      return fromAPObject(url, doc, author);
-    }
-  }
-
-  // 2. The oEmbed registry: a cheap in-memory match, then one small API call.
-  //    Tried before the page because it is far cheaper AND because the big
-  //    platforms are exactly the ones that hide their tags from a server.
-  if (io.registry && io.getJSON) {
-    const providers = await io.registry().catch(() => null);
-    const endpoint = matchProviderEndpoint(url, providers);
-    if (endpoint) {
-      const o = await io.getJSON(oembedRequestUrl(endpoint, url)).catch(() => null);
-      const card = fromOEmbed(url, o);
-      if (card) return card;
-    }
-  }
-
-  // 3. oEmbed via the page, then OpenGraph. One page fetch serves both: oEmbed
-  //    is the richer protocol, OpenGraph is the one most of the web ships.
-  //
-  //    There is deliberately NO list of known providers here. A hardcoded list
-  //    is a whitelist you have to keep maintaining, and it was actively harmful:
-  //    a YouTube link matched the list, short-circuited before oEmbed, and came
-  //    out as a card with no title and no thumbnail, so nothing was stored at
-  //    all. YouTube serves both oEmbed and og:image like everyone else, so the
-  //    generic path handles it better than the special case did.
-  if (io.getPage) {
-    const page = await io.getPage(url).catch(() => null);
-    if (page) {
-      const endpoint = findOEmbedEndpoint(page);
-      if (endpoint && io.getJSON) {
-        const o = await io.getJSON(endpoint).catch(() => null);
-        const card = fromOEmbed(url, o);
-        if (card) return card;
-      }
-      const og = findOpenGraph(page);
-      if (og) {
-        return {
-          kind: 'opengraph',
-          url,
-          title: og.title,
-          author: og.site ? { name: og.site, handle: null, icon: null } : null,
-          provider: og.site,
-          html: null,
-          media: og.image ? [{ url: og.image, type: 'image/*' }] : [],
-        };
-      }
-    }
-  }
-
-  // 4. Nothing recognised it: a link stays a link.
-  return { kind: 'link', url, media: [] };
-}
-
-// ── The wired-up variant ──────────────────────────────────────────
-// The io above is injected so the ordering is testable without a network.
-// This binds it to the real, SSRF-safe fetchers. Every fetch is capped and
-// goes through safeFetch (which refuses private ranges and caps redirects),
-// so a hostile URL in a post cannot make the server probe an internal host.
-
-const MAX_JSON = 512_000;   // an oEmbed/AP payload must parse whole, so cap and refuse
-// Of a web page we only ever need the <head>. The cap has to clear the worst
-// real case rather than the tidy one: YouTube ships ~665kB of inline script
-// before its og:image and closes <head> at ~673kB, and at 512kB we cut the page
-// off just short of the tags and produced nothing. We stop as soon as the tags
-// are in hand, so a normal page still costs a few dozen kB.
-const MAX_HEAD = 1_048_576;
-const UA = 'Mozilla/5.0 (compatible; Klonkt/1.0; +https://klonkt.com)';
-
-/** A whole small document, refused when it is too big to be one. */
-async function safeJsonText(safeFetch, url, accept) {
-  try {
-    const r = await safeFetch(url, { headers: { Accept: accept } });
-    if (!r.ok) return null;
-    if (Number(r.headers.get('content-length') || 0) > MAX_JSON) return null;
-    const body = await r.text();
-    return body.length > MAX_JSON ? null : body;   // truncated JSON is useless
-  } catch { return null; }
-}
-
-/**
- * The START of a web page, streamed and cut off at MAX_HEAD.
- *
- * Refusing a page for being large was wrong: YouTube's watch page is megabytes,
- * so it was rejected outright and never produced a thumbnail, even though its
- * og:image sits in the first few kilobytes like everyone else's. We only ever
- * read the <head>, so read that much and stop pulling. The cap still protects
- * us from someone streaming us an endless body.
- */
-async function safeHead(safeFetch, url, extra = {}) {
-  try {
-    const r = await safeFetch(url, { headers: { Accept: 'text/html,application/xhtml+xml', ...extra } });
-    if (!r.ok) return null;
-    if (!r.body || typeof r.body.getReader !== 'function') {
-      const body = await r.text();                       // no stream (or a test double)
-      return body.length > MAX_HEAD ? body.slice(0, MAX_HEAD) : body;
-    }
-    const reader = r.body.getReader();
-    const dec = new TextDecoder('utf-8');
-    const parts = [];
-    let len = 0;
-    let tail = '';        // carry a little context so a tag split across chunks still matches
-    let done_ = false;
-    while (!done_) {
-      const { done, value } = await reader.read();
-      if (done) break;
-      const chunk = dec.decode(value, { stream: true });
-      parts.push(chunk);
-      len += chunk.length;
-      // Scan only the new chunk (plus overlap), not the whole buffer: testing
-      // the full string every read turns a 1MB page into quadratic work.
-      const window = tail + chunk;
-      // Stop on the real <meta property="og:image">, not on the bare string.
-      // Big sites carry "og:image" inside inline JSON long before the actual
-      // tag, and stopping there cut the page off just short of the meta block:
-      // the same near-miss as the old size cap, with a different cause.
-      if (len >= MAX_HEAD || /<\/head>/i.test(window) || /<meta[^>]{0,300}og:image/i.test(window)) done_ = true;
-      tail = chunk.slice(-512);
-    }
-    try { await reader.cancel(); } catch { /* already closed */ }
-    return parts.join('').slice(0, MAX_HEAD);
-  } catch { return null; }
-}
-
-/**
- * Bind the resolver to the live fetchers.
- * @param {object} deps - { safeFetch, detectProvider, actorInfo, fetchActor }
- */
-// The provider registry, fetched once and kept for a day. It is a public list
-// maintained by oembed.com, not by us; if it is unreachable we simply fall back
-// to page discovery, so nothing breaks, it just gets less clever.
-const REGISTRY_URL = 'https://oembed.com/providers.json';
-const REGISTRY_TTL = 24 * 60 * 60 * 1000;
-let _registry = null;
-let _registryAt = 0;
-
-export function liveIO({ safeFetch, detectProvider, fetchActor, actorInfo }) {
-  return {
-    registry: async () => {
-      if (_registry && Date.now() - _registryAt < REGISTRY_TTL) return _registry;
-      const body = await safeJsonText(safeFetch, REGISTRY_URL, 'application/json');
-      if (!body) return _registry;                       // keep a stale list over none
-      try { _registry = JSON.parse(body); _registryAt = Date.now(); } catch { /* keep the old one */ }
-      return _registry;
-    },
-    provider: detectProvider ? (u) => { try { return detectProvider(u); } catch { return null; } } : null,
-    getAP: async (u) => {
-      const body = await safeJsonText(safeFetch, u, 'application/activity+json, application/ld+json');
-      if (!body) return null;
-      try { return JSON.parse(body); } catch { return null; }   // an HTML page is simply not AP
-    },
-    // Plenty of sites only hand out their OpenGraph tags to something that
-    // looks like a browser, so the page fetch identifies itself.
-    getPage: (u) => safeHead(safeFetch, u, { 'User-Agent': UA }),
-    getJSON: async (u) => {
-      const body = await safeJsonText(safeFetch, u, 'application/json');
-      if (!body) return null;
-      try { return JSON.parse(body); } catch { return null; }
-    },
-    actorOf: async (uri) => {
-      if (!fetchActor || !actorInfo) return null;
-      const doc = await fetchActor(uri).catch(() => null);
-      if (!doc) return null;
-      const ai = actorInfo(doc, uri);
-      return { name: ai.name, handle: ai.handle, icon: ai.icon, emojis: ai.emojis };
-    },
-  };
-}
-
-export default { resolveEmbed, findOEmbedEndpoint, findOpenGraph, matchProviderEndpoint, oembedRequestUrl, looksLikeAPObject, fromOEmbed, fromAPObject, liveIO };
Index: src/services/HtmlSanitizerService.js
===================================================================
--- src/services/HtmlSanitizerService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/services/HtmlSanitizerService.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -25,25 +25,17 @@
   'strong', 'em', 'b', 'i', 'u', 's', 'mark', 'small', 'sub', 'sup',
   'code', 'a', 'span', 'img',
-  // Native media (bare .webm/.mp4/.mp3 embeds + federated-in players)
-  'video', 'audio', 'source',
 ];
 
 // Per-tag attribute allowlist. '*' applies to every tag.
 const ALLOWED_ATTRS = {
-  '*':   ['class', 'id', 'dir', 'lang', 'data-sc'],
-  a:     ['href', 'title', 'target', 'rel'],
-  img:   ['src', 'alt', 'title', 'width', 'height', 'loading'],
-  video: ['src', 'controls', 'preload', 'poster', 'width', 'height', 'loop', 'muted', 'autoplay', 'playsinline'],
-  audio: ['src', 'controls', 'preload', 'loop', 'muted', 'autoplay'],
-  source: ['src', 'type'],
+  '*': ['class', 'id', 'dir', 'lang', 'data-sc'],
+  a:   ['href', 'title', 'target', 'rel'],
+  img: ['src', 'alt', 'title', 'width', 'height', 'loading'],
 };
 
 const ALLOWED_SCHEMES = ['http', 'https', 'mailto', 'tel'];
 const ALLOWED_SCHEMES_BY_TAG = {
-  img:    ['http', 'https', 'data'],
-  a:      ['http', 'https', 'mailto', 'tel'],
-  video:  ['http', 'https'],
-  audio:  ['http', 'https'],
-  source: ['http', 'https'],
+  img: ['http', 'https', 'data'],
+  a:   ['http', 'https', 'mailto', 'tel'],
 };
 
Index: src/services/ImageWebpService.js
===================================================================
--- src/services/ImageWebpService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,40 +1,0 @@
-/**
- * Convert a freshly uploaded image to WebP (smaller, modern format).
- *
- * Uses the system `cwebp` (libwebp). Present → convert + delete the original,
- * return the new .webp filename. Not present or error → return the original
- * filename (graceful fallback, nothing breaks).
- *
- * GIF stays GIF (cwebp cannot produce animated WebP from a GIF); already-WebP
- * files are skipped.
- */
-import { execFileSync } from 'child_process';
-import fs from 'fs';
-import path from 'path';
-
-const QUALITY = '82';
-
-/**
- * @param {{path:string, filename:string, destination?:string}} file  multer file
- * @returns {string} the final filename (basename) — .webp or the original
- */
-export function toWebp(file) {
-  if (!file || !file.path || !file.filename) return file && file.filename;
-  const ext = path.extname(file.filename).toLowerCase();
-  if (ext === '.webp' || ext === '.gif') return file.filename;
-  const dir = file.destination || path.dirname(file.path);
-  const outName = path.basename(file.filename, ext) + '.webp';
-  const outPath = path.join(dir, outName);
-  try {
-    execFileSync('cwebp', ['-quiet', '-q', QUALITY, file.path, '-o', outPath], { stdio: 'ignore' });
-    if (!fs.existsSync(outPath) || fs.statSync(outPath).size === 0) throw new Error('empty output');
-    try { fs.unlinkSync(file.path); } catch { /* original gone, not critical */ }
-    return outName;
-  } catch (e) {
-    console.warn('[webp] conversion skipped (cwebp not available/error):', e.message);
-    try { if (fs.existsSync(outPath)) fs.unlinkSync(outPath); } catch {} // clean up partial output
-    return file.filename; // keep original
-  }
-}
-
-export default { toWebp };
Index: src/services/MigrationService.js
===================================================================
--- src/services/MigrationService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,959 +1,0 @@
-/**
- * MigrationService.js — FEP-1580: je OBJECTEN verhuizen bij een Move.
- *
- * FEP-7628 verhuist je volgers en zegt zelf dat de rest een ander probleem is.
- * Dit is dat andere probleem: na een Move stonden je berichten nog op de oude
- * instantie, en elke reactie van een derde wees naar een URI die verdwijnt zodra
- * dat domein opgezegd wordt.
- *
- * DRIE DINGEN OM TE WETEN VOOR JE HIERIN LEEST:
- *
- * 1. DE AUTORISATIE IS DE MOVE, NIET EEN CODE. De bronkant staat in
- *    ActivityPubService.isMoveTarget: een ondertekend verzoek namens de actor
- *    waar de bron naartoe verhuisde telt als de bron zelf. Dat mag omdat
- *    moveAccount() `no_backreference` weigert, dus `moved_to` staat er alleen
- *    als iemand met beheer op BEIDE kanten dat wilde. Hier in dit bestand zit
- *    de DOELkant, die van die toestemming gebruikmaakt.
- *
- * 2. NIEUWE IDS ZIJN GEEN BUG, DE VERTAALTABEL IS HET ANTWOORD. Een gemigreerd
- *    bericht krijgt hier een eigen URI, want het staat nu op een ander domein.
- *    De `migration`-collectie mapt oud naar nieuw en derden lezen die om hun
- *    eigen verwijzingen bij te werken. Zonder die collectie is de draad kapot,
- *    met die collectie is het een verhuisbericht.
- *
- * 3. ER GAAT GEEN Create DE DEUR UIT. De spec is daar expliciet over, en het is
- *    ook gewoon logisch: je volgers hebben deze berichten jaren geleden al
- *    gezien. Een ingest van driehonderd posts die als driehonderd nieuwe posts
- *    de tijdlijn in klettert is geen verhuizing maar spam.
- *
- * WAT HIER ONTBREEKT: FEP-8b32 integrity proofs (shaer-j1v0). De `moves`-
- * collectie hoort ondertekend te zijn en de Moves erin horen een proof van de
- * bron-actor te dragen. Klonkt kent 8b32 nog niet. We bewaren wel alle
- * grondstof (de rauwe activity en het actordocument), zodat het later alleen
- * ondertekenen is. Bewust geen leeg proof-veld: een derde die het controleert
- * wordt dan misleid, en dat is erger dan een veld dat ontbreekt.
- */
-import crypto from 'crypto';
-import db from '../config/database.js';
-import { AP_CONTEXT, actorId, pagedCollection, PAGINA_GROOTTE } from './ap-core.js';
-
-// ── Vertaaltabel ──────────────────────────────────────────────────
-
-const stmts = {};
-function q(naam, sql) { return (stmts[naam] ||= db.prepare(sql)); }
-
-/** Leg vast dat `origin` hier `target` werd. Idempotent: opnieuw draaien mag. */
-export function recordMigrated(slug, { origin, target, sourceActor = '', isPublic = true } = {}) {
-  if (!slug || !origin || !target) return false;
-  try {
-    q('ins', `INSERT INTO ap_migration (slug, origin, target, source_actor, is_public)
-              VALUES (?, ?, ?, ?, ?)
-              ON CONFLICT(slug, origin) DO UPDATE SET target = excluded.target`)
-      .run(slug, String(origin), String(target), String(sourceActor || ''), isPublic ? 1 : 0);
-    return true;
-  } catch (e) {
-    console.warn('[FEP-1580] mapping niet opgeslagen:', origin, e && e.message);
-    return false;
-  }
-}
-
-/**
- * De items, nieuwste kopie eerst.
- *
- * `alles` is alleen waar voor een geverifieerde lezer uit het publiek van de
- * niet-publieke objecten. De spec: Moves voor objecten die niet aan as:Public
- * gericht zijn MOGEN NIET publiek getoond worden. Een migration-collectie die
- * de URIs van je fan-only posts opsomt is een lek, ook zonder de inhoud.
- */
-export function migrationItems(slug, { alles = false, limit = null, offset = 0 } = {}) {
-  try {
-    // IN SQL pagineren, niet in geheugen. Dit is een PUBLIEK endpoint dat
-    // derden volgens FEP-1580 juist herhaaldelijk ophalen tot migrationComplete
-    // waar is. Alles laden om er twintig te tonen is dan geen inefficientie
-    // maar een hefboom: bij honderdduizend berichten bouwt elke poll
-    // honderdduizend objecten die meteen de prullenbak in gaan.
-    const sql = `SELECT origin, target, source_actor FROM ap_migration
-                 WHERE slug = ?${alles ? '' : ' AND is_public = 1'}
-                 ORDER BY id DESC${limit ? ' LIMIT ? OFFSET ?' : ''}`;
-    return limit ? db.prepare(sql).all(slug, limit, offset) : db.prepare(sql).all(slug);
-  } catch { return []; }
-}
-
-export function migrationCount(slug, { alles = false } = {}) {
-  try {
-    const sql = `SELECT COUNT(*) n FROM ap_migration WHERE slug = ?${alles ? '' : ' AND is_public = 1'}`;
-    return db.prepare(sql).get(slug).n;
-  } catch { return 0; }
-}
-
-/** Is deze URI hier al binnen? Houdt een tweede ingest-ronde goedkoop. */
-export function alGemigreerd(slug, origin) {
-  try { return !!db.prepare('SELECT 1 FROM ap_migration WHERE slug = ? AND origin = ?').get(slug, String(origin)); } catch { return false; }
-}
-
-/**
- * Waar kwam deze bron-URI hier terecht? Null als hij nog niet gemigreerd is.
- *
- * Bestaat omdat "al gehad" en "overslaan" niet hetzelfde horen te zijn. Een
- * tweede ronde na een uitgebreide ingest (hoezen, duur, playlists erbij) moet
- * de bestaande nummers KUNNEN AANVULLEN in plaats van ze te passeren. Deed hij
- * dat niet, dan zat je vast: opnieuw ophalen sloeg alles over, en opruimen hielp
- * niet omdat deze tabel de blokkade in stand hield.
- */
-export function migrationTarget(slug, origin) {
-  try {
-    const r = db.prepare('SELECT target FROM ap_migration WHERE slug = ? AND origin = ?').get(slug, String(origin));
-    return r ? r.target : null;
-  } catch { return null; }
-}
-
-// ── De Move-activities ────────────────────────────────────────────
-
-export function recordMove(slug, { moveId, sourceActor, targetActor, activity, actorDoc = null } = {}) {
-  if (!slug || !moveId || !sourceActor || !targetActor) return false;
-  try {
-    q('insMove', `INSERT INTO ap_moves (slug, move_id, source_actor, target_actor, activity_json, actor_json)
-                  VALUES (?, ?, ?, ?, ?, ?)
-                  ON CONFLICT(slug, move_id) DO NOTHING`)
-      .run(slug, String(moveId), String(sourceActor), String(targetActor),
-        JSON.stringify(activity || {}), actorDoc ? JSON.stringify(actorDoc) : null);
-    return true;
-  } catch (e) {
-    console.warn('[FEP-1580] Move niet opgeslagen:', moveId, e && e.message);
-    return false;
-  }
-}
-
-export function moveRows(slug) {
-  try { return db.prepare('SELECT * FROM ap_moves WHERE slug = ? ORDER BY id').all(slug); } catch { return []; }
-}
-
-// ── Stand van zaken ───────────────────────────────────────────────
-
-export function migrationComplete(slug) {
-  try {
-    const r = db.prepare('SELECT migration_complete FROM sites WHERE slug = ?').get(slug);
-    // Geen kolom of geen rij telt als "klaar": een site die nooit verhuisde
-    // heeft niets openstaan, en derden moeten niet eeuwig blijven pollen.
-    return !r || r.migration_complete === null || r.migration_complete === undefined ? true : !!r.migration_complete;
-  } catch { return true; }
-}
-
-export function setMigrationComplete(slug, klaar) {
-  try { db.prepare('UPDATE sites SET migration_complete = ? WHERE slug = ?').run(klaar ? 1 : 0, slug); } catch { /* kolom ontbreekt op een oude db */ }
-}
-
-// ── De collecties ─────────────────────────────────────────────────
-
-/**
- * De `migration`-collectie. Items zijn Move-activities per OBJECT (niet per
- * actor): origin is de oude URI, target de nieuwe.
- *
- * De spec wil URI-verwijzingen in origin/target in plaats van ingesloten
- * objecten, en paginering. `pagedCollection` doet dat al voor de rest van
- * Klonkt, dus die gebruiken we ook hier.
- */
-export function buildMigration(base, site, { page = false, alles = false } = {}) {
-  const me = actorId(base, site.slug);
-  const id = `${me}/migration`;
-  const totaal = migrationCount(site.slug, { alles });
-  // Zonder pagina: alleen de omslag met eerste/laatste en de telling. Zo hoeft
-  // de kale collectie geen enkele rij aan te raken, en dat is precies wat een
-  // consument als eerste opvraagt.
-  const nr = page ? Math.max(1, Math.floor(Number(page)) || 1) : false;
-  const rows = nr ? migrationItems(site.slug, { alles, limit: PAGINA_GROOTTE, offset: (nr - 1) * PAGINA_GROOTTE }) : [];
-  const items = rows.map((r) => ({
-    type: 'Move',
-    actor: r.source_actor || undefined,
-    origin: r.origin,
-    target: r.target,
-  }));
-  return pagedCollection(id, items, {
-    page: nr,
-    totalItems: totaal,
-    alGesneden: true,
-    extra: {
-      attributedTo: me,
-      moves: `${me}/moves`,
-      migrationComplete: migrationComplete(site.slug),
-    },
-  });
-}
-
-/**
- * De `moves`-collectie: de Move-activities zelf, met het bron-actordocument
- * ingesloten zoals de spec aanraadt ("Source instances SHOULD inline the source
- * Actor object"), zodat een lezer de proof kan nakijken zonder de bron nog te
- * kunnen bereiken. Dat laatste is precies het geval waarvoor dit bestaat.
- *
- * Zonder FEP-8b32 (shaer-j1v0) ontbreekt de handtekening. Zie de kop.
- */
-export function buildMoves(base, site) {
-  const me = actorId(base, site.slug);
-  const rows = moveRows(site.slug);
-  const orderedItems = rows.map((r) => {
-    let act = {};
-    try { act = JSON.parse(r.activity_json) || {}; } catch { /* onleesbaar, dan de kale vorm hieronder */ }
-    let actorDoc = null;
-    try { actorDoc = r.actor_json ? JSON.parse(r.actor_json) : null; } catch { /* idem */ }
-    return {
-      id: r.move_id,
-      type: 'Move',
-      origin: r.source_actor,
-      target: r.target_actor,
-      actor: actorDoc || r.source_actor,
-      ...(act.published ? { published: act.published } : {}),
-    };
-  });
-  return {
-    '@context': AP_CONTEXT,
-    id: `${me}/moves`,
-    type: 'OrderedCollection',
-    attributedTo: me,
-    totalItems: orderedItems.length,
-    orderedItems,
-  };
-}
-
-// ── Wat de UI wil weten ───────────────────────────────────────────
-
-export function migrationStatus(slug) {
-  return {
-    total: migrationCount(slug, { alles: true }),
-    publiek: migrationCount(slug),
-    moves: moveRows(slug).length,
-    complete: migrationComplete(slug),
-  };
-}
-
-/** Een id dat nergens mee botst, in de vorm die de rest van Klonkt gebruikt. */
-export function nieuwId() { return crypto.randomUUID(); }
-
-// ── De ingest: van de bron hierheen ───────────────────────────────
-
-/** Vrije slug binnen deze site. Botst hij, dan -2, -3, enzovoort. */
-function vrijeSlug(siteId, basis) {
-  const schoon = String(basis || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'bericht';
-  const bestaat = db.prepare('SELECT 1 FROM posts WHERE site_id = ? AND slug = ?');
-  if (!bestaat.get(siteId, schoon)) return schoon;
-  for (let n = 2; n < 500; n++) if (!bestaat.get(siteId, `${schoon}-${n}`)) return `${schoon}-${n}`;
-  return `${schoon}-${crypto.randomBytes(4).toString('hex')}`;
-}
-
-/** Het kale id uit een track-URI: .../tracks/t-een -> t-een. */
-function ruwId(uri) {
-  try { return decodeURIComponent(String(uri).split('/').filter(Boolean).pop() || ''); } catch { return ''; }
-}
-
-/** De laatste padcomponent van een URI, als beginpunt voor een slug. */
-function slugUitUri(uri) {
-  try { return decodeURIComponent(new URL(uri).pathname.split('/').filter(Boolean).pop() || ''); } catch { return ''; }
-}
-
-const AFBEELDING = /^image\//i;
-
-/**
- * Links naar de BRONPOSTS ombuigen naar hier.
- *
- * De gebakken content zit vol met https://oud/<slug>#track-<id> en
- * https://oud/<slug>?fc=2: de "luister op"-links die buildNote maakt. Die
- * blijven naar de oude site wijzen, en dat is een tijdbom, want zodra dat
- * domein opgezegd wordt zijn het dode links in je eigen berichten.
- *
- * Kan pas als ALLE posts binnen zijn, en alleen voor een slug die hier echt
- * bestaat. Een link naar iets dat we niet hebben laten we met rust: dan is een
- * verwijzing naar de oude site nog altijd beter dan een 404 op de nieuwe.
- *
- * De #track-<id>-fragmenten kloppen vanzelf, want sinds "altijd behouden" is
- * dat id hier hetzelfde.
- */
-export function postLinksBijtrekken(site, bronOrigin, rapport = {}) {
-  if (!bronOrigin) return 0;
-  let n = 0;
-  const rijen = db.prepare('SELECT id, content FROM posts WHERE site_id = ? AND content LIKE ?')
-    .all(site.id, `%${bronOrigin}/%`);
-  if (!rijen.length) return 0;
-  const heeftSlug = db.prepare('SELECT 1 FROM posts WHERE site_id = ? AND slug = ?');
-  const upd = db.prepare('UPDATE posts SET content = ? WHERE id = ?');
-  const patroon = new RegExp(`${bronOrigin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/([A-Za-z0-9._~-]+)`, 'g');
-  for (const r of rijen) {
-    let inhoud = String(r.content || '');
-    let raak = false;
-    for (const m of [...new Set([...inhoud.matchAll(patroon)].map((x) => x[1]))]) {
-      // media en audio lopen via hun eigen weg; hier gaat het om postpagina's
-      if (m === 'media' || m === 'audio' || m === 'ap') continue;
-      if (!heeftSlug.get(site.id, m)) continue;
-      inhoud = inhoud.split(`${bronOrigin}/${m}`).join(`/${m}`);
-      raak = true;
-    }
-    if (raak) { upd.run(inhoud, r.id); n++; }
-  }
-  if (n) {
-    rapport.linksBijgetrokken = n;
-    console.log('[FEP-1580] postlinks bijgetrokken in', n, 'bericht(en)');
-  }
-  return n;
-}
-
-/**
- * Waar kan de omslag van een bericht zitten?
- *
- * Niet alleen in `attachment`. Klonkt onderdrukt de beeldbijlage met opzet
- * zodra een post een speler of embed heeft (zie noImages in buildNote), en zet
- * de cover dan in `image` zodat Mastodon zijn spelerkaart toont en een Klonkt
- * hem alsnog vindt. Dat is precies wat er bij Robin misging: 18 van de 20
- * berichten op pagina 1 hadden geen enkele bijlage, en toch een cover, en die
- * viel er stil tussenuit.
- *
- * En een Audio-bijlage draagt zijn eigen hoes in `icon`; die telt ook mee.
- */
-function coverKandidaten(o) {
-  const uit = [];
-  const pak = (v) => {
-    if (!v) return;
-    const u = typeof v === 'string' ? v : (v.url && (typeof v.url === 'string' ? v.url : v.url.href)) || v.href;
-    if (u && /^https?:\/\//i.test(String(u))) uit.push(String(u));
-  };
-  pak(o.image);
-  pak(o.icon);
-  for (const a of (Array.isArray(o.attachment) ? o.attachment : [])) pak(a && a.icon);
-  return [...new Set(uit)];
-}
-
-/**
- * Hoort deze URL bij de bron, en wijst hij onder /media/?
- *
- * Dan behouden we het PAD. Drie redenen tegelijk:
- *   - de gebakken content verwijst relatief of absoluut naar dat pad, en met
- *     hetzelfde pad hier klopt elke verwijzing zonder herschrijf-acrobatiek;
- *   - de media-bibliotheek (Beheer, Media) scant de MAP post-images, niet de
- *     databasetabel. Een bestand onder migrated/<uuid> bestaat wel en is
- *     onzichtbaar: Robins lege images-tab;
- *   - de zip-import bewaart originele paden al, dus zo convergeren beide
- *     routes op dezelfde bestanden.
- *
- * De ../-bewaking is geen formaliteit: het pad komt van een andere server.
- */
-function bronMediaPad(url, bronOrigin, { mediaRoot, path }) {
-  try {
-    const u = new URL(String(url));
-    if (`${u.protocol}//${u.host}` !== bronOrigin) return null;
-    if (!u.pathname.startsWith('/media/')) return null;
-    const rel = decodeURIComponent(u.pathname.slice('/media/'.length));
-    const abs = path.resolve(mediaRoot, rel);
-    const root = path.resolve(mediaRoot);
-    if (abs === root || !abs.startsWith(`${root}${path.sep}`)) return null;
-    return { rel: `/media/${rel}`, abs };
-  } catch { return null; }
-}
-
-/** AS2 geeft de duur als ISO-8601 ("PT212S"), de database wil seconden. */
-function duurSeconden(v) {
-  if (v == null) return null;
-  if (typeof v === 'number') return Math.round(v) || null;
-  const m = /^P(?:.*?T)?(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?$/.exec(String(v));
-  if (!m) { const n = Number(v); return Number.isFinite(n) && n > 0 ? Math.round(n) : null; }
-  const sec = (Number(m[1]) || 0) * 3600 + (Number(m[2]) || 0) * 60 + (Number(m[3]) || 0);
-  return sec > 0 ? Math.round(sec) : null;
-}
-
-/**
- * De titel terugwinnen uit de content.
- *
- * Een AS2 Note heeft geen titel: Mastodon negeert `name`, dus Klonkt bakt de
- * titel als vetgedrukte eerste alinea IN de content (zie buildNote). Over de
- * lijn is een titel dus geen veld maar een vorm. Doen we hier niets, dan komt
- * elk bericht titelloos aan en heet het naar zijn id.
- *
- * Daarom draaien we precies onze eigen bak terug: alleen als de content BEGINT
- * met een alinea die niets anders bevat dan vetgedrukte tekst. Dat is de exacte
- * vorm die buildNote maakt. Een bericht van elders dat toevallig zo begint
- * verliest die regel niet, hij verhuist naar het titelveld en staat straks
- * gewoon weer bovenaan.
- */
-function titelUitContent(html) {
-  const m = /^\s*<p>\s*<strong>([\s\S]*?)<\/strong>\s*<\/p>/i.exec(String(html || ''));
-  if (!m) return { titel: null, rest: html };
-  const titel = m[1].replace(/<[^>]+>/g, '').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').trim();
-  if (!titel || titel.length > 300) return { titel: null, rest: html };
-  return { titel, rest: String(html).slice(m[0].length) };
-}
-
-/**
- * Haal een bijlage op en zet hem lokaal neer.
- *
- * safeFetch is de SSRF-veilige kant van Klonkt; hier is dat geen formaliteit,
- * want de URL komt van een andere server. Een bron die ons naar 127.0.0.1 wijst
- * moet stranden, ook als die bron "van onszelf" is.
- */
-async function haalBijlage(url, { safeFetch, mediaRoot, fs, path, maxBytes, submap = 'migrated', headers = null, doel = null }) {
-  // Ondertekend als het moet. Gehoste audio zit achter dezelfde poort als de
-  // rest van de bron, en een kale fetch krijgt daar een 403: de bron kan dan
-  // niet zien dat wij de doel-actor van zijn Move zijn.
-  const r = await safeFetch(url, { headers: headers || { accept: '*/*' } }).catch(() => null);
-  if (!r || !r.ok) return null;
-  const buf = Buffer.from(await r.arrayBuffer());
-  if (!buf.length || buf.length > maxBytes) return null;
-  const type = String(r.headers.get('content-type') || '').split(';')[0].trim() || 'application/octet-stream';
-  const ext = (() => {
-    const uit = slugUitUri(url);
-    const m = /\.([a-z0-9]{1,5})$/i.exec(uit);
-    if (m) return m[1].toLowerCase();
-    return (type.split('/')[1] || 'bin').replace(/[^a-z0-9]/gi, '').slice(0, 5) || 'bin';
-  })();
-  const naam = `${crypto.randomUUID()}.${ext}`;
-  // `doel` wint: dan behouden we het pad van de bron (zie bronMediaPad).
-  // Zonder submap komt het bestand in de root zelf: dat is wat gehoste audio
-  // nodig heeft, want de speler zoekt AUDIO_ROOT + bestandsnaam en kijkt niet
-  // in mappen eronder.
-  const rel = doel ? doel.rel : (submap ? `${submap}/${naam}` : naam);
-  const abs = doel ? doel.abs : (submap ? path.join(mediaRoot, submap, naam) : path.join(mediaRoot, naam));
-  fs.mkdirSync(path.dirname(abs), { recursive: true });
-  fs.writeFileSync(abs, buf);
-  // doel.rel is al een volledig /media/-pad; de submap-variant is dat nog niet.
-  return { url: doel ? doel.rel : `/media/${rel}`, mediaType: type, size: buf.length, filename: naam, storage_path: abs };
-}
-
-/**
- * Alle bron-media in een lap HTML binnenhalen en de verwijzingen relatief maken.
- *
- * Werkt op ALLE https://bron/media/...-voorkomens, niet alleen op <img src>:
- * de gebakken content zet dezelfde URL ook in een href om het plaatje groot te
- * openen, en een half herschreven paar (lokaal plaatje, hotlink eromheen) is
- * verwarrender dan geen herschrijving.
- *
- * Idempotent: wat al gedownload is wordt niet opnieuw gehaald, en een tweede
- * ronde over dezelfde tekst vindt gewoon niets meer te doen.
- */
-async function inhoudMediaBinnen(html, bronOrigin, site, rapport, { safeFetch, mediaRoot, fs, path, maxBytes }) {
-  let inhoud = String(html || '');
-  if (!inhoud || !bronOrigin) return { inhoud, n: 0 };
-  // LET OP de dubbele backslash: dit is een STRING die een RegExp wordt. Met een
-  // enkele \s eet de template literal de backslash op en sluit de klasse de
-  // LETTER s uit; "post-images" knapte dan af op de s en elke URL met een s
-  // erin werd half herschreven. Gevonden doordat de waarschuwing ".../media/po"
-  // meldde, afgekapt precies voor de s.
-  const patroon = new RegExp(`${bronOrigin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(/media/[^"'\\s)<>]+)`, 'g');
-  const gezien = new Set();
-  let n = 0;
-  for (const m of [...inhoud.matchAll(patroon)]) {
-    const vol = m[0];
-    if (gezien.has(vol)) continue;
-    gezien.add(vol);
-    const doel = bronMediaPad(vol, bronOrigin, { mediaRoot, path });
-    if (!doel) { rapport.waarschuwingen.push(`onbruikbaar mediapad in tekst: ${vol}`); continue; }
-    let ok = false;
-    try { fs.statSync(doel.abs); ok = true; } catch { /* nog niet binnen */ }
-    if (!ok) {
-      const g = await haalBijlage(vol, { safeFetch, mediaRoot, fs, path, maxBytes, doel }).catch(() => null);
-      if (!g) { rapport.mediaMislukt++; rapport.waarschuwingen.push(`plaatje in tekst niet opgehaald: ${vol}`); continue; }
-      rapport.media++;
-      try {
-        db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?, ?, ?, ?, ?, ?)')
-          .run(crypto.randomUUID(), site.id, path.basename(doel.abs), g.mediaType, g.size, doel.abs);
-      } catch { /* administratie */ }
-    }
-    inhoud = inhoud.split(vol).join(doel.rel);
-    n++;
-  }
-  return { inhoud, n };
-}
-
-/**
- * FEP-1580 ingest-routine, de doelkant.
- *
- * De autorisatie wordt hier niet verzonnen maar NAGEKEKEN, en in beide
- * richtingen, precies zoals de spec het voor derden voorschrijft: `movedTo` op
- * de bron moet naar ons wijzen EN wij moeten de bron in `alsoKnownAs` hebben.
- * Eén kant is een bewering, twee kanten is een afspraak. Zou ik alleen op onze
- * eigen alsoKnownAs afgaan, dan kon iedereen die zichzelf een alias geeft de
- * geschiedenis van een vreemde opeisen.
- *
- * `deps` is er voor de test: die moet dit kunnen draaien zonder netwerk.
- */
-export async function ingestFromSource(site, {
-  sourceUri = null, max = 1000, maxBytes = 25 * 1024 * 1024, deps = {},
-} = {}) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !site || !site.slug) return { error: 'config' };
-  const me = actorId(base, site.slug);
-
-  const {
-    getJson = null, safeFetch = null, mediaRoot = null, fs = null, path = null, noteId = null,
-    sanitize = (h) => h,
-    // Standaard 'followers': kan iets niet als publiek bewezen worden, dan
-    // hoort het niet in de publieke vertaaltabel. Fail-closed, want dit is een
-    // privacygrens en niet een weergavedetail.
-    noteVisibility = () => 'followers',
-    audioRoot = null, signHeaders = null,
-  } = deps;
-  const zichtbaarheid = noteVisibility;
-  if (!getJson || !noteId) return { error: 'config' };
-
-  // 1. Welke bron? Zonder opgave: de alias die we zelf claimen.
-  let bron = sourceUri && /^https?:\/\//i.test(sourceUri) ? sourceUri : null;
-  if (!bron) {
-    try {
-      const aka = JSON.parse(site.ap_aliases || '[]');
-      bron = Array.isArray(aka) ? aka.find((u) => typeof u === 'string' && /^https?:\/\//i.test(u)) || null : null;
-    } catch { /* stukke ap_aliases telt als geen alias */ }
-  }
-  if (!bron) return { error: 'no_source' };
-
-  // 2 + 3. Het bron-actordocument, en de wegwijzer die naar ONS moet wijzen.
-  const bronActor = await getJson(site.slug, bron);
-  if (!bronActor || !bronActor.id) return { error: 'unreachable' };
-  // De origin van de bron: alles op deze host onder /media/ is van hem en mag
-  // naar hetzelfde pad hier. Uit de actor-id, niet uit de invoer.
-  const bronOrigin = (() => { try { const u = new URL(bronActor.id); return `${u.protocol}//${u.host}`; } catch { return null; } })();
-  if (bronActor.movedTo !== me) return { error: 'not_moved_here', movedTo: bronActor.movedTo || null };
-
-  // 4. En de terugverwijzing van onze kant, zodat het een afspraak is.
-  const eigenAka = (() => {
-    try { const a = JSON.parse(site.ap_aliases || '[]'); return Array.isArray(a) ? a : []; } catch { return []; }
-  })();
-  if (!eigenAka.includes(bronActor.id)) return { error: 'no_backreference' };
-
-  // 5 + 6. Vastleggen dat dit een migratie is, en de deur openzetten voor derden.
-  recordMove(site.slug, {
-    moveId: `${bronActor.id}#move`, sourceActor: bronActor.id, targetActor: me,
-    activity: { type: 'Move', actor: bronActor.id, object: bronActor.id, target: me },
-    actorDoc: bronActor,
-  });
-  setMigrationComplete(site.slug, false);
-
-  const rapport = {
-    bron: bronActor.id, posts: 0, overgeslagen: 0, opnieuw: 0, postsBijgewerkt: 0, media: 0, mediaMislukt: 0,
-    blocks: 0, tracksBinnen: 0, tracksMislukt: 0, tracksBijgewerkt: 0, tracksLinks: 0, overgeslagenTracks: 0,
-    playlistsBinnen: 0, playlistsMislukt: 0, waarschuwingen: [],
-  };
-
-  try {
-    // 7. BLOKKADES EERST. De spec is daar streng over, en terecht: ze bepalen
-    //    wie de rest te zien krijgt. Andersom importeer je even je hele
-    //    geschiedenis zichtbaar voor iemand die je nou juist buiten wilde.
-    if (bronActor.blocked) {
-      const coll = await getJson(site.slug, typeof bronActor.blocked === 'string' ? bronActor.blocked : bronActor.blocked.id);
-      const lijst = (coll && (coll.orderedItems || coll.items)) || [];
-      for (const b of Array.isArray(lijst) ? lijst : []) {
-        const uri = typeof b === 'string' ? b : (b && (b.object || b.id));
-        if (!uri || !/^https?:\/\//i.test(String(uri))) continue;
-        try {
-          db.prepare("INSERT OR IGNORE INTO ap_blocks (slug, target, kind, label) VALUES (?, ?, 'actor', NULL)").run(site.slug, String(uri));
-          rapport.blocks++;
-        } catch { /* tabel ontbreekt op een verse db */ }
-      }
-    } else {
-      rapport.waarschuwingen.push('de bron gaf geen blokkadelijst, zichtbaarheidsvoorkeuren komen niet mee');
-    }
-
-    // 8. De outbox aflopen. Pagineren zoals de rest van Klonkt dat doet.
-    if (!bronActor.outbox) return { ...rapport, error: 'no_outbox' };
-    let pagina = await getJson(site.slug, typeof bronActor.outbox === 'string' ? bronActor.outbox : bronActor.outbox.id);
-    const verwacht = pagina && Number(pagina.totalItems) || null;
-    // Is er een `first`, dan ALTIJD de paginaketen volgen, ook als de kale
-    // collectie zelf items draagt. Klonkt zet daar een kopie van pagina 1 in
-    // (Pleroma eiste een first, en sindsdien staan ze er allebei), maar alleen
-    // echte pagina's dragen een `next`. Wie op de kale collectie blijft hangen
-    // verwerkt pagina 1 en denkt dan klaar te zijn: precies 18 van Robins 35
-    // berichten, zonder één waarschuwing.
-    if (pagina && pagina.first) {
-      pagina = await getJson(site.slug, typeof pagina.first === 'string' ? pagina.first : pagina.first.id);
-    }
-
-    const insPost = db.prepare(`INSERT INTO posts
-      (id, site_id, slug, author_id, title, content, excerpt, status, cover_image_url,
-       pinned, type, tags, published_at, created_at, updated_at, fan_only, nsfw, language,
-       content_warning, ap_visibility, c2s_attachments, origin_server)
-      VALUES (@id, @site_id, @slug, @author_id, @title, @content, NULL, 'published', @cover_image_url,
-       0, 'post', @tags, @published_at, @published_at, @updated_at, @fan_only, @nsfw, @language,
-       @content_warning, @ap_visibility, @c2s_attachments, 'migrated')`);
-
-    let gezien = 0;
-    while (pagina && gezien < max) {
-      const items = (pagina.orderedItems || pagina.items) || [];
-      for (const it of Array.isArray(items) ? items : []) {
-        if (gezien >= max) break;
-        const o = (it && typeof it.object === 'object' && it.object) ? it.object : it;
-        if (!o || !o.id) continue;
-        if (o.type && !['Note', 'Article', 'Question'].includes(o.type)) continue;
-        if (o.inReplyTo) continue;                                    // toplevel; antwoorden hangen aan hun ouder
-        const auteur = typeof o.attributedTo === 'string' ? o.attributedTo : (o.attributedTo && o.attributedTo.id);
-        if (auteur && auteur !== bronActor.id) continue;              // alleen wat van HEM was
-        gezien++;
-        // Het interne id BLIJFT (Robins besluit, 14-8). Daarmee is "staat hij
-        // hier al" gewoon een blik in de tabel, en niet iets dat je uit een
-        // aparte mapping moet afleiden. Verwijder je een bericht en haal je
-        // opnieuw op, dan komt het gewoon terug: er staat immers niets meer.
-        const id = ruwId(o.id) || crypto.randomUUID();
-        const bestaand = db.prepare('SELECT id, content, cover_image_url FROM posts WHERE id = ? AND site_id = ?').get(id, site.id);
-        if (bestaand) {
-          // Niet alleen overslaan: REPAREREN wat een eerdere ronde liet liggen.
-          // Robins 18 posts stonden er al, met hotlinks naar de bron in de
-          // tekst en zonder cover. Een tweede ronde die dat ziet en passeert
-          // laat je met een site vol verwijzingen naar een domein dat
-          // opgezegd wordt.
-          if (safeFetch && fs && path && mediaRoot && bronOrigin && String(bestaand.content || '').includes(bronOrigin)) {
-            const r2 = await inhoudMediaBinnen(bestaand.content, bronOrigin, site, rapport, { safeFetch, mediaRoot, fs, path, maxBytes });
-            if (r2.n) {
-              db.prepare('UPDATE posts SET content = ? WHERE id = ?').run(r2.inhoud, bestaand.id);
-              rapport.postsBijgewerkt++;
-            }
-          }
-          if (!bestaand.cover_image_url && safeFetch && fs && path && mediaRoot) {
-            // De omslag alsnog. Uit de beeldbijlage als die er is, anders uit
-            // image/icon: bij een post met een speler staat hij daar.
-            const uit = [
-              ...(Array.isArray(o.attachment) ? o.attachment : [])
-                .filter((a) => AFBEELDING.test(String((a && a.mediaType) || '')))
-                .map((a) => (typeof a.url === 'string' ? a.url : (a.url && a.url.href)))
-                .filter(Boolean),
-              ...coverKandidaten(o),
-            ];
-            for (const u of uit) {
-              const doel = bronMediaPad(u, bronOrigin, { mediaRoot, path });
-              const g = await haalBijlage(String(u), { safeFetch, mediaRoot, fs, path, maxBytes, doel }).catch(() => null);
-              if (!g) continue;
-              db.prepare('UPDATE posts SET cover_image_url = ? WHERE id = ?').run(g.url, bestaand.id);
-              rapport.media++;
-              rapport.postsBijgewerkt++;
-              break;
-            }
-          }
-          rapport.overgeslagen++;
-          continue;
-        }
-        if (migrationTarget(site.slug, o.id)) rapport.opnieuw++;   // was er, is weg, komt terug
-
-        // Media eerst, want een post die naar een plaatje wijst dat we niet
-        // hebben opgehaald is een halve post. Mislukt een bijlage, dan gaat de
-        // post wel door en staat het in het verslag.
-        const bijlagen = Array.isArray(o.attachment) ? o.attachment : [];
-        const binnen = [];
-        let inhoud = o.content || '';
-        if (safeFetch && fs && path && mediaRoot) {
-          for (const a of bijlagen.slice(0, 20)) {
-            const u = a && (typeof a === 'string' ? a : (a.url && (typeof a.url === 'string' ? a.url : a.url.href)));
-            if (!u || !/^https?:\/\//i.test(String(u))) continue;
-            const doel = bronMediaPad(u, bronOrigin, { mediaRoot, path });
-            const g = await haalBijlage(String(u), { safeFetch, mediaRoot, fs, path, maxBytes, doel }).catch(() => null);
-            if (!g) { rapport.mediaMislukt++; rapport.waarschuwingen.push(`bijlage niet opgehaald: ${u}`); continue; }
-            binnen.push({ ...g, naam: (a && a.name) || null, type: (a && a.mediaType) || g.mediaType });
-            rapport.media++;
-            try {
-              db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?, ?, ?, ?, ?, ?)')
-                .run(crypto.randomUUID(), site.id, g.filename, g.mediaType, g.size, g.storage_path);
-            } catch { /* media-rij is administratie, het bestand staat er */ }
-          }
-          // De PLAATJES IN DE TEKST. De gebakken content draagt absolute
-          // verwijzingen naar de bron (https://oud/media/...), en die bleven
-          // gewoon staan: elke afbeelding hotlinkte naar een domein dat je gaat
-          // opzeggen, en je eigen mediamap bleef leeg. Downloaden naar
-          // HETZELFDE pad en de verwijzing relatief maken; wat niet lukt blijft
-          // absoluut staan en wordt gemeld, want een lokale 404 is erger dan
-          // een hotlink.
-          const r2 = await inhoudMediaBinnen(inhoud, bronOrigin, site, rapport, { safeFetch, mediaRoot, fs, path, maxBytes });
-          inhoud = r2.inhoud;
-          // De omslag zit lang niet altijd in attachment (zie coverKandidaten).
-          if (!binnen.some((b) => AFBEELDING.test(b.type || ''))) {
-            for (const u of coverKandidaten(o)) {
-              const doel = bronMediaPad(u, bronOrigin, { mediaRoot, path });
-              const g = await haalBijlage(u, { safeFetch, mediaRoot, fs, path, maxBytes, doel }).catch(() => null);
-              if (!g) { rapport.mediaMislukt++; rapport.waarschuwingen.push(`omslag niet opgehaald: ${u}`); continue; }
-              binnen.unshift({ ...g, naam: null, type: g.mediaType });
-              rapport.media++;
-              try {
-                db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?, ?, ?, ?, ?, ?)')
-                  .run(crypto.randomUUID(), site.id, g.filename, g.mediaType, g.size, g.storage_path);
-              } catch { /* administratie */ }
-              break;                       // een omslag is genoeg
-            }
-          }
-        }
-
-        const cover = binnen.find((b) => AFBEELDING.test(b.type || ''));
-        const rest = binnen.filter((b) => b !== cover);
-        // De titel zit in de content, niet in een veld (zie titelUitContent).
-        const { titel, rest: body } = o.name ? { titel: o.name, rest: inhoud } : titelUitContent(inhoud);
-        // De slug uit de MENSELIJKE url, niet uit de AP-id. Zo houdt het bericht
-        // hetzelfde webadres als op de oude instantie, en blijft een link die
-        // iemand ergens plakte kloppen op het nieuwe domein.
-        const basisSlug = slugUitUri(o.url || '') || o.name || slugUitUri(o.id) || id;
-        // De publicatiedatum blijft die van het origineel. De spec eist dat, en
-        // het is ook het enige eerlijke: het bericht is niet vandaag geschreven.
-        insPost.run({
-          id, site_id: site.id, slug: vrijeSlug(site.id, basisSlug),
-          author_id: site.owner_id, title: titel || null, content: sanitize(body || ''),
-          cover_image_url: cover ? cover.url : null,
-          tags: Array.isArray(o.tag) ? o.tag.filter((t) => t && t.type === 'Hashtag').map((t) => String(t.name || '').replace(/^#/, '')).filter(Boolean).join(', ') || null : null,
-          published_at: o.published || null, updated_at: o.updated || o.published || null,
-          fan_only: 0, nsfw: o.sensitive ? 1 : 0,
-          language: (o.contentMap && Object.keys(o.contentMap)[0]) || null,
-          content_warning: o.summary || null,
-          ap_visibility: null,
-          c2s_attachments: rest.length ? JSON.stringify(rest.map((b) => ({ url: b.url, mediaType: b.type, name: b.naam || undefined }))) : null,
-        });
-        recordMigrated(site.slug, {
-          origin: o.id, target: noteId(base, id), sourceActor: bronActor.id,
-          // Publiek in de zin van de spec: gericht aan as:Public. Zo niet, dan
-          // hoort deze regel niet in een publiek leesbare migration-pagina.
-          //
-          // Via noteVisibility en niet met een eigen test op '#Public': die kent
-          // ook de schrijfwijzen 'as:Public' en 'Public', en de rest van Klonkt
-          // beslist er al mee. Een tweede, dunnere versie van dezelfde vraag is
-          // precies hoe twee antwoorden uit elkaar gaan lopen.
-          isPublic: zichtbaarheid(o) === 'public',
-        });
-        rapport.posts++;
-      }
-      const volgende = pagina.next;
-      if (!volgende || gezien >= max) break;
-      pagina = await getJson(site.slug, typeof volgende === 'string' ? volgende : volgende.id);
-    }
-    if (gezien >= max) rapport.waarschuwingen.push(`gestopt bij ${max} berichten, draai het nog eens voor de rest`);
-    // Silently minder ophalen dan de bron zegt te hebben is precies hoe 18 van
-    // de 35 wekenlang op "klaar" had kunnen staan. Tel na en zeg het.
-    if (verwacht && gezien < verwacht && gezien < max) {
-      rapport.waarschuwingen.push(`de bron meldt ${verwacht} items en er zijn er ${gezien} verwerkt; een pagina is mogelijk niet opgehaald, probeer het nog eens`);
-    }
-
-    // ── De muziekbibliotheek ──────────────────────────────────────
-    //
-    // Losse nummers staan niet in de outbox: die hangen aan de tracks-collectie
-    // waar de actor via AS2 `streams` naar wijst. Zonder deze lus verhuist een
-    // muzieksite zijn berichten en laat hij zijn bibliotheek achter.
-    //
-    // De bron geeft ons hier alles, niet alleen de fedi_open-nummers, omdat we
-    // de doel-actor van zijn Move zijn (siteOpenTracks({alles})). Hetzelfde
-    // geldt voor de bestanden zelf, die anders achter de gated audio-route
-    // blijven.
-    const trackKaart = new Map();   // bron-URI van een nummer -> ons nieuwe id
-    // En het RUWE id zoals het in de posttekst staat. Klonkt schrijft
-    // [[track:<id>]] in de content, en die tekst reist letterlijk mee over AP.
-    // Krijgt het nummer hier een ander id, dan wijst die shorthand nergens meer
-    // heen en zie je de code zelf in je bericht staan.
-    const ruwKaart = new Map();     // ruw bron-id -> ons id
-    const streams = [].concat(bronActor.streams || []).filter((u) => typeof u === 'string');
-    const tracksUrl = streams.find((u) => /\/tracks\/?$/.test(u));
-    if (tracksUrl && safeFetch && fs && path && audioRoot) {
-      const coll = await getJson(site.slug, tracksUrl);
-      const lijst = (coll && (coll.orderedItems || coll.items)) || [];
-      for (const it of (Array.isArray(lijst) ? lijst : []).slice(0, max)) {
-        const a = (it && typeof it.object === 'object' && it.object) ? it.object : it;
-        if (!a || !a.id) continue;
-        if (a.type && a.type !== 'Audio') continue;
-        // AL BINNEN? Dan AANVULLEN, niet overslaan. Een tweede ronde bestaat
-        // juist omdat er iets bij is gekomen (hoezen, duur, playlists), en een
-        // pull die dan alles passeert laat je met een half resultaat zitten
-        // zonder uitweg: opruimen hielp niet, want deze tabel hield de blokkade
-        // in stand.
-        //
-        // Alleen LEGE velden worden gevuld. Wat jij zelf hebt aangepast blijft
-        // staan; een migratie hoort je correcties niet terug te draaien.
-        const trackId = ruwId(a.id) || crypto.randomUUID();
-        {
-          const rij = db.prepare('SELECT id, cover_url, duration, artist FROM audio_tracks WHERE id = ? AND site_id = ?')
-            .get(trackId, site.id);
-          if (rij) {
-            trackKaart.set(String(a.id), rij.id);   // MOET, anders vinden de playlists hem niet
-            ruwKaart.set(ruwId(a.id), rij.id);
-            const duur = rij.duration ? null : duurSeconden(a.duration);
-            const artiest = rij.artist ? null : (a.summary || a.artist || null);
-            let hoes = null;
-            const hUrl = (a.icon && (a.icon.url || a.icon)) || (a.image && (a.image.url || a.image)) || null;
-            if (!rij.cover_url && hUrl && /^https?:\/\//i.test(String(hUrl)) && safeFetch && fs && path && mediaRoot) {
-              const h = await haalBijlage(String(hUrl), {
-                safeFetch, mediaRoot, fs, path, maxBytes,
-                headers: signHeaders ? signHeaders(site.slug, String(hUrl), '*/*') : null,
-              }).catch(() => null);
-              if (h) { hoes = h.url; rapport.media++; }
-            }
-            if (duur || artiest || hoes) {
-              db.prepare(`UPDATE audio_tracks SET
-                            duration = COALESCE(?, duration),
-                            artist = COALESCE(?, artist),
-                            cover_url = COALESCE(?, cover_url)
-                          WHERE id = ?`).run(duur, artiest, hoes, rij.id);
-              rapport.tracksBijgewerkt++;
-            } else {
-              rapport.overgeslagenTracks++;
-            }
-            continue;
-          }
-        }
-        // Uit de url-lijst de LINK NAAR HET BESTAND vissen, niet zomaar de eerste:
-        // buildTrackAudio zet er ook een text/html-link naar de post voor. En een
-        // LINK-ONLY track (alleen Spotify of YouTube, nooit een gehost bestand)
-        // heeft er helemaal geen. Die hoort gewoon mee, met media_id NULL.
-        // Weggooien kostte Robin een nummer dat het op de oude site prima deed.
-        const urls = [].concat(a.url || []).map((u) => (typeof u === 'string' ? { href: u } : u)).filter((u) => u && u.href);
-        const bestandLink = urls.find((u) => /^audio\//i.test(String(u.mediaType || '')))
-          || urls.find((u) => /\/audio\/stream\//.test(String(u.href)));
-        const externe = urls.map((u) => String(u.href)).filter((h) => /spotify|youtube|youtu\.be|soundcloud|bandcamp/i.test(h));
-        const bron = bestandLink && bestandLink.href;
-        if (!bron || !/^https?:\/\//i.test(String(bron))) {
-          if (externe.length) {
-            const trackIdL = ruwId(a.id) || crypto.randomUUID();
-            try {
-              db.prepare(`INSERT OR REPLACE INTO audio_tracks
-                  (id, site_id, title, artist, media_id, link_spotify, link_youtube, link_soundcloud, fedi_open)
-                VALUES (?,?,?,?,NULL,?,?,?,0)`)
-                .run(trackIdL, site.id, a.name || 'zonder titel', a.summary || a.artist || null,
-                  externe.find((u) => /spotify/i.test(u)) || null,
-                  externe.find((u) => /youtube|youtu\.be/i.test(u)) || null,
-                  externe.find((u) => /soundcloud/i.test(u)) || null);
-              recordMigrated(site.slug, { origin: a.id, target: `${me}/ap/tracks/${trackIdL}`, sourceActor: bronActor.id, isPublic: false });
-              trackKaart.set(String(a.id), trackIdL);
-              ruwKaart.set(ruwId(a.id), trackIdL);
-              rapport.tracksLinks++;
-              rapport.tracksBinnen++;
-            } catch (e) {
-              rapport.tracksMislukt++;
-              rapport.waarschuwingen.push(`nummer niet opgeslagen: ${a.name || a.id} (${e && e.message})`);
-            }
-            continue;
-          }
-          rapport.tracksMislukt++;
-          continue;
-        }
-        const g = await haalBijlage(String(bron), {
-          safeFetch, mediaRoot: audioRoot, fs, path, maxBytes, submap: '',
-          headers: signHeaders ? signHeaders(site.slug, String(bron), '*/*') : null,
-        }).catch(() => null);
-        if (!g) {
-          rapport.tracksMislukt++;
-          rapport.waarschuwingen.push(`nummer niet opgehaald: ${a.name || bron}`);
-          continue;                       // dezelfde regel als bij de zip: geen bestand, geen track
-        }
-        // De hoes. Die reisde als URL wel mee en als bestand niet, dus kwam een
-        // nummer aan met een verwijzing naar een plaatje dat er niet is.
-        let hoes = null;
-        const hoesUrl = (a.icon && (a.icon.url || a.icon)) || (a.image && (a.image.url || a.image)) || null;
-        if (hoesUrl && /^https?:\/\//i.test(String(hoesUrl))) {
-          const h = await haalBijlage(String(hoesUrl), {
-            safeFetch, mediaRoot, fs, path, maxBytes,
-            headers: signHeaders ? signHeaders(site.slug, String(hoesUrl), '*/*') : null,
-          }).catch(() => null);
-          if (h) { hoes = h.url; rapport.media++; }
-          else rapport.waarschuwingen.push(`hoes niet opgehaald: ${a.name || hoesUrl}`);
-        }
-        const mediaId = crypto.randomUUID();
-        try {
-          db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
-            .run(mediaId, site.id, g.filename, g.mediaType, g.size, g.storage_path);
-          db.prepare(`INSERT INTO audio_tracks (id, site_id, title, artist, album, duration, media_id, cover_url, fedi_open)
-                      VALUES (?,?,?,?,?,?,?,?,0)`)
-            .run(trackId, site.id, a.name || 'zonder titel', a.summary || a.artist || null, a.album || null,
-              duurSeconden(a.duration), mediaId, hoes);
-          recordMigrated(site.slug, { origin: a.id, target: `${me}/ap/tracks/${trackId}`, sourceActor: bronActor.id, isPublic: false });
-          trackKaart.set(String(a.id), trackId);
-          ruwKaart.set(ruwId(a.id), trackId);
-          rapport.tracksBinnen++;
-        } catch (e) {
-          rapport.tracksMislukt++;
-          rapport.waarschuwingen.push(`nummer niet opgeslagen: ${a.name || a.id} (${e && e.message})`);
-        }
-      }
-    } else if (tracksUrl) {
-      rapport.waarschuwingen.push('muziekbibliotheek overgeslagen: geen audiomap meegegeven');
-    }
-
-    // Postlinks eerst: pas nu zijn ALLE berichten binnen, dus pas nu weten we
-    // welke slugs hier bestaan.
-    postLinksBijtrekken(site, bronOrigin, rapport);
-
-    // ── De verwijzingen in de tekst bijtrekken ────────────────────
-    //
-    // Klonkt schrijft [[track:<id>]] in posts.content, en die tekst reist
-    // letterlijk mee. Krijgt het nummer hier een ander id, dan wijst de
-    // shorthand nergens heen en zie je de code zelf in je bericht staan in
-    // plaats van een speler. Precies wat Robin op TikTik zag.
-    //
-    // Pas NA de tracks, want daarvoor is de kaart nog leeg. En alleen waar het
-    // id echt veranderde: een gelijk id hoeft niet aangeraakt.
-    {
-      const paren = [...ruwKaart.entries()].filter(([oud, nieuwId]) => oud && oud !== nieuwId);
-      if (paren.length) {
-        const upd = db.prepare('UPDATE posts SET content = REPLACE(content, ?, ?) WHERE site_id = ? AND content LIKE ?');
-        let n = 0;
-        for (const [oud, nieuwId] of paren) {
-          const r = upd.run(`[[track:${oud}]]`, `[[track:${nieuwId}]]`, site.id, `%[[track:${oud}]]%`);
-          if (r && r.changes) n += r.changes;
-        }
-        if (n) { rapport.tekstBijgewerkt = n; console.log('[FEP-1580] track-verwijzingen bijgetrokken in', n, 'bericht(en)'); }
-      }
-    }
-
-    // ── De playlists ──────────────────────────────────────────────
-    //
-    // Los van de nummers, want de VOLGORDE is de playlist. Die staat nergens
-    // anders: haal je alleen de tracks op, dan heb je wel alle muziek en geen
-    // enkele plaat. De bron geeft ons de volledige lijst omdat we de doel-actor
-    // zijn; anders zaten er alleen de opengezette nummers in en kreeg je een
-    // plaat met gaten.
-    const plUrl = streams.find((u) => /\/playlists\/?$/.test(u));
-    if (plUrl && trackKaart.size) {
-      const coll = await getJson(site.slug, plUrl);
-      const lijst = (coll && (coll.orderedItems || coll.items)) || [];
-      for (const p of (Array.isArray(lijst) ? lijst : []).slice(0, 200)) {
-        const uri = typeof p === 'string' ? p : (p && p.id);
-        if (!uri) continue;
-        const plc = typeof p === 'object' && (p.orderedItems || p.items) ? p : await getJson(site.slug, uri);
-        if (!plc) { rapport.playlistsMislukt++; continue; }
-        const nummers = (plc.orderedItems || plc.items || [])
-          .map((x) => (x && typeof x === 'object' ? x.id : x))
-          .map((id) => trackKaart.get(String(id)))
-          .filter(Boolean);
-        if (!nummers.length) {
-          rapport.waarschuwingen.push(`playlist ${plc.name || uri}: geen van de nummers is aangekomen, overgeslagen`);
-          continue;
-        }
-        // De hoes van de plaat, net als bij een nummer.
-        let plHoes = null;
-        const plHoesUrl = (plc.icon && (plc.icon.url || plc.icon)) || (plc.image && (plc.image.url || plc.image)) || null;
-        if (plHoesUrl && /^https?:\/\//i.test(String(plHoesUrl)) && safeFetch && fs && path && mediaRoot) {
-          const h = await haalBijlage(String(plHoesUrl), {
-            safeFetch, mediaRoot, fs, path, maxBytes,
-            headers: signHeaders ? signHeaders(site.slug, String(plHoesUrl), '*/*') : null,
-          }).catch(() => null);
-          if (h) { plHoes = h.url; rapport.media++; }
-          else rapport.waarschuwingen.push(`hoes van playlist niet opgehaald: ${plc.name || uri}`);
-        }
-        // Ook hier het id van de bron. Dan blijft [[playlist:<id>]] in een
-        // bericht wijzen, en is een tweede ronde vanzelf dezelfde rij.
-        const plId = ruwId(uri) || crypto.randomUUID();
-        try {
-          db.prepare(`INSERT INTO playlists (id, site_id, title, artist, year, kind, cover_url) VALUES (?,?,?,?,?,?,?)
-                      ON CONFLICT(id) DO UPDATE SET
-                        title = excluded.title,
-                        artist = COALESCE(playlists.artist, excluded.artist),
-                        cover_url = COALESCE(playlists.cover_url, excluded.cover_url)`)
-            .run(plId, site.id, plc.name || 'zonder titel', plc.artist || null,
-              plc.year || null, plc['shaer:kind'] || null, plHoes);
-          // De volgorde opnieuw zetten: die IS de plaat, en een halve
-          // bijgewerkte volgorde is erger dan een verse.
-          db.prepare('DELETE FROM playlist_tracks WHERE playlist_id = ?').run(plId);
-          const ins = db.prepare('INSERT OR IGNORE INTO playlist_tracks (playlist_id, track_id, position) VALUES (?,?,?)');
-          nummers.forEach((tid, i) => ins.run(plId, tid, i));
-          recordMigrated(site.slug, { origin: uri, target: `${me}/ap/playlists/${plId}`, sourceActor: bronActor.id, isPublic: false });
-          rapport.playlistsBinnen++;
-          const kwijt = (plc.orderedItems || plc.items || []).length - nummers.length;
-          if (kwijt > 0) rapport.waarschuwingen.push(`playlist ${plc.name || uri}: ${kwijt} nummer(s) ontbraken en zijn eruit gelaten`);
-        } catch (e) {
-          rapport.playlistsMislukt++;
-          rapport.waarschuwingen.push(`playlist niet opgeslagen: ${plc.name || uri} (${e && e.message})`);
-        }
-      }
-    }
-  } catch (e) {
-    // 9-bij-mislukking: de vlag blijft OPEN staan. Derden blijven dan kijken,
-    // en dat is precies goed, want er is nog werk.
-    console.warn('[FEP-1580] ingest afgebroken:', e && e.message);
-    return { ...rapport, error: 'partial', melding: e && e.message };
-  }
-
-  // 9. Klaar. Nu pas mag een derde stoppen met kijken.
-  setMigrationComplete(site.slug, true);
-  console.log('[FEP-1580] ingest klaar:', site.slug, '<-', bronActor.id, rapport.posts, 'berichten,', rapport.media, 'bestanden');
-  return rapport;
-}
Index: src/services/MusicBrainzService.js
===================================================================
--- src/services/MusicBrainzService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,160 +1,0 @@
-/**
- * Een artiest zoekt zichzelf op in MusicBrainz (shaer-mbz).
- *
- * WAAROM DIT GEEN DIALECT IS. Funkwhale's Track/Artist/ArtistCredit zijn hun
- * eigen vocabulaire -- hun docs noemen ze letterlijk "Custom Funkwhale object"
- * -- en wij kunnen ze niet eerlijk vullen: artiest en album zijn bij ons
- * tekstkolommen, geen entiteiten. Een MBID is iets anders: geen vocabulaire
- * maar een REGISTER. Ernaar verwijzen is als een ISBN noemen. Je neemt niemands
- * model over en je wijst naar iets dat al bestaat.
- *
- * WAT HIER NIET GEBEURT: schrijven. Via hun API zijn alleen tags, ratings,
- * ISRC's en barcodes in te dienen -- artiesten, releases en recordings niet,
- * dat gaat via hun website. Wij lezen dus alleen, en dat is meteen de
- * geruststelling: we kunnen hun register niet vervuilen.
- *
- * TWEE HARDE REGELS VAN HUN KANT, allebei hieronder ingebakken omdat ze bij
- * overtreding tot blokkade leiden en niet tot een foutmelding:
- *   - hoogstens EEN verzoek per seconde, per applicatie (niet per bezoeker)
- *   - een echte User-Agent, met contactgegevens
- */
-import { safeFetch } from './ActivityPubService.js';
-// De twee pure vormcontroles wonen in ap-core: ActivityPubService heeft ze ook
-// nodig voor de actor, en zonder die verhuizing zou dat een KRINGLOOP zijn --
-// deze module leent immers safeFetch dáár.
-import { isMbid, artiestUrl } from './ap-core.js';
-
-const BASIS = 'https://musicbrainz.org/ws/2';
-
-/**
- * De User-Agent die MusicBrainz eist. Hun regel: naam, versie en een manier om
- * contact op te nemen. Een lege of generieke string is precies waarop ze
- * blokkeren, dus als er geen contact is ingesteld zeggen we dat met zoveel
- * woorden in plaats van iets aardigs te verzinnen.
- */
-function userAgent() {
-  const contact = (process.env.MUSICBRAINZ_CONTACT || '').trim()
-    || (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '')
-    || 'geen-contact-ingesteld';
-  return `Klonkt/1.0 ( ${contact} )`;
-}
-
-/**
- * Hun tempo aanhouden: ten hoogste een verzoek per seconde, over de HELE
- * applicatie. Geen bibliotheek en geen wachtrij -- een belofte die de volgende
- * aanroeper laat wachten tot het weer mag. Zonder dit is de eerste drukke dag
- * meteen een blokkade, en dan werkt het bij iedereen niet meer.
- */
-let laatste = 0;
-let beurt = Promise.resolve();
-function opDeBeurt() {
-  beurt = beurt.then(async () => {
-    const wachten = 1000 - (Date.now() - laatste);
-    if (wachten > 0) await new Promise((r) => setTimeout(r, wachten));
-    laatste = Date.now();
-  });
-  return beurt;
-}
-
-/**
- * Zoek artiesten op naam. Geeft de kandidaten met alles wat nodig is om er EEN
- * uit te kiezen -- de naam alleen is niet genoeg, want er zijn drie bands die
- * Nirvana heten. Vandaar disambiguation, land en de jaren erbij.
- *
- * Geeft een LEGE lijst bij een storing, geen exceptie: niet kunnen zoeken is
- * vervelend, maar het mag het beheerscherm niet omvergooien.
- */
-export async function zoekArtiesten(naam, { limit = 8 } = {}) {
-  const q = String(naam || '').trim();
-  if (!q) return [];
-  const url = `${BASIS}/artist?query=${encodeURIComponent(q)}&fmt=json&limit=${Math.min(25, Math.max(1, limit))}`;
-  try {
-    await opDeBeurt();
-    const r = await safeFetch(url, { headers: { Accept: 'application/json', 'User-Agent': userAgent() } });
-    if (!r || !r.ok) return [];
-    const doc = await r.json();
-    return (doc.artists || []).map(kandidaat).filter(Boolean);
-  } catch {
-    return [];
-  }
-}
-
-/**
- * Een MBID rechtstreeks opzoeken. Wie zijn id al kent hoeft niet te zoeken --
- * en een zoekopdracht op een UUID levert bij MusicBrainz niets op, dus zonder
- * deze tak zou plakken juist het slechtste resultaat geven.
- */
-export async function haalArtiest(mbid) {
-  if (!isMbid(mbid)) return null;
-  const url = `${BASIS}/artist/${encodeURIComponent(mbid)}?inc=url-rels&fmt=json`;
-  try {
-    await opDeBeurt();
-    const r = await safeFetch(url, { headers: { Accept: 'application/json', 'User-Agent': userAgent() } });
-    if (!r || !r.ok) return null;
-    return kandidaat(await r.json());
-  } catch {
-    return null;
-  }
-}
-
-/**
- * DE TERUG-WEG. Noemt de MusicBrainz-pagina van deze artiest ons domein?
- *
- * Een koppeling van onze kant is een bewering: iedereen kan een MBID in een
- * veld typen. Pas als de artiestenpagina TERUGWIJST is het een paar, en dan
- * weet een lezer dat dezelfde persoon aan allebei de kanten stond. Dat is
- * dezelfde gedachte als rel="me" bij Mastodon.
- *
- * Wij zetten die terugwijzing NIET zelf: via hun API kan het niet, en het hoort
- * ook niet -- de artiest doet dat op musicbrainz.org onder "social networking".
- * Wij kijken alleen of hij er staat.
- *
- * Geeft { verified, urls } -- bij een storing verified:false en een lege lijst,
- * want niet kunnen kijken is niet hetzelfde als niet gevonden.
- */
-export async function controleerTerugweg(mbid, domein) {
-  const leeg = { verified: false, urls: [] };
-  if (!isMbid(mbid) || !domein) return leeg;
-  let host;
-  try { host = new URL(domein).host.toLowerCase(); } catch { return leeg; }
-  const url = `${BASIS}/artist/${encodeURIComponent(mbid)}?inc=url-rels&fmt=json`;
-  try {
-    await opDeBeurt();
-    const r = await safeFetch(url, { headers: { Accept: 'application/json', 'User-Agent': userAgent() } });
-    if (!r || !r.ok) return leeg;
-    const doc = await r.json();
-    const urls = (doc.relations || [])
-      .map((rel) => rel && rel.url && rel.url.resource)
-      .filter((u) => typeof u === 'string');
-    const wijst = urls.some((u) => { try { return new URL(u).host.toLowerCase() === host; } catch { return false; } });
-    return { verified: wijst, urls };
-  } catch {
-    return leeg;
-  }
-}
-
-/** Een kandidaat, teruggebracht tot wat een mens nodig heeft om te kiezen. */
-function kandidaat(a) {
-  if (!a || !a.id || !a.name) return null;
-  const jaren = [a['life-span']?.begin, a['life-span']?.ended ? a['life-span']?.end : null]
-    .filter(Boolean).join(' – ');
-  return {
-    mbid: a.id,
-    naam: a.name,
-    // "disambiguation" is het veld waarmee MusicBrainz zelf twee gelijknamige
-    // artiesten uit elkaar houdt. Precies wat de kiezer nodig heeft.
-    toelichting: a.disambiguation || '',
-    soort: a.type || '',            // Person, Group, ...
-    land: a.country || '',
-    jaren,
-    url: `https://musicbrainz.org/artist/${a.id}`,
-    // Hun eigen zoekscore. Niet om op te sorteren -- dat doen zij al -- maar om
-    // een zwakke treffer te kunnen tonen als zwak.
-    score: Number(a.score) || 0,
-  };
-}
-
-// Her-geexporteerd zodat een aanroeper er niet over hoeft na te denken waar
-// ze precies wonen.
-export { isMbid, artiestUrl };
-export default { zoekArtiesten, haalArtiest, controleerTerugweg, isMbid, artiestUrl };
Index: src/services/MusicMeta.js
===================================================================
--- src/services/MusicMeta.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,87 +1,0 @@
-// Phase 1 of music federation: emit STANDARD schema.org MusicRecording / MusicAlbum
-// structured data for an audio post (Google rich results + any generic JSON-LD consumer).
-// Deliberately a real, existing web standard — NOT a Klonkt-invented field. The track
-// resolution here is reused by the (future) Funkwhale Audio/Library federation (Phase 2).
-import db from '../config/database.js';
-
-const COLS = 'title, album, duration, credit, license, cover_url, media_id';
-
-function isoDuration(sec) {
-  const n = parseInt(sec, 10);
-  if (!n || n < 0) return null;
-  return `PT${Math.floor(n / 60)}M${n % 60}S`; // ISO-8601 duration, e.g. PT3M20S
-}
-function absUrl(base, u) {
-  if (!u) return null;
-  return /^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`;
-}
-
-// Resolve a post's [[track]]/[[album]]/[[playlist]] shortcodes to the HOSTED (playable)
-// tracks it references — only file-backed tracks (media_id), mirroring hasPlayableAudio.
-function resolveTracks(site, content) {
-  const tracks = [];
-  try {
-    for (const m of content.matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
-      const r = db.prepare(`SELECT ${COLS} FROM audio_tracks WHERE id = ?`).get(m[1]);
-      if (r && r.media_id) tracks.push(r);
-    }
-    for (const m of content.matchAll(/\[\[album:([^\]]+)\]\]/g)) {
-      for (const r of db.prepare(`SELECT ${COLS} FROM audio_tracks WHERE site_id = ? AND album = ? AND media_id IS NOT NULL ORDER BY rowid`).all(site.id, m[1].trim())) tracks.push(r);
-    }
-    for (const m of content.matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) {
-      for (const r of db.prepare(`SELECT t.title, t.album, t.duration, t.credit, t.license, t.cover_url, 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 ORDER BY pt.position`).all(m[1])) tracks.push(r);
-    }
-  } catch { /* non-fatal */ }
-  return tracks;
-}
-
-// Build a schema.org MusicRecording (single track) or MusicAlbum (multiple) for a post,
-// or null when the post has no hosted audio. `url` points to the gated player page — the
-// anti-steal posture is preserved (no raw file URL is ever emitted).
-export function build(base, site, post) {
-  if (!post || !site || !post.content) return null;
-  if (!/\[\[(track|album|playlist):/i.test(post.content)) return null;
-  const b = (base || '').replace(/\/+$/, '');
-  const tracks = resolveTracks(site, post.content);
-  if (!tracks.length) return null;
-
-  const artist = {
-    '@type': 'MusicGroup',
-    name: site.title || site.slug,
-    url: `${b}/${site.is_primary ? '' : 'user/' + encodeURIComponent(site.slug)}`,
-  };
-  const postUrl = `${b}/${encodeURIComponent(post.slug)}`;
-  const recording = (t, withTop) => {
-    const o = { '@type': 'MusicRecording', name: t.title || post.title || 'Untitled' };
-    if (withTop) { o.byArtist = artist; o.url = postUrl; }
-    if (t.album) o.inAlbum = { '@type': 'MusicAlbum', name: t.album };
-    const d = isoDuration(t.duration); if (d) o.duration = d;
-    if (t.license) o.license = t.license;     // e.g. "CC BY 4.0" — Klonkt leads on this
-    if (t.credit) o.creditText = t.credit;
-    const cov = absUrl(b, t.cover_url) || absUrl(b, post.cover_image_url); if (cov) o.image = cov;
-    return o;
-  };
-
-  let ld;
-  if (tracks.length > 1) {
-    const albums = [...new Set(tracks.map((t) => t.album).filter(Boolean))];
-    ld = {
-      '@type': 'MusicAlbum',
-      name: albums.length === 1 ? albums[0] : (post.title || 'Album'),
-      byArtist: artist,
-      url: postUrl,
-      numTracks: tracks.length,
-      track: tracks.map((t) => recording(t, false)),
-    };
-    const cov = absUrl(b, post.cover_image_url) || absUrl(b, tracks[0].cover_url); if (cov) ld.image = cov;
-    const lic = tracks.find((t) => t.license); if (lic) ld.license = lic.license;
-  } else {
-    ld = recording(tracks[0], true);
-  }
-  ld['@context'] = 'https://schema.org';
-  const dp = post.published_at || post.created_at;
-  if (dp) ld.datePublished = new Date(dp).toISOString();
-  return ld;
-}
-
-export default { build };
Index: src/services/NoteRender.js
===================================================================
--- src/services/NoteRender.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,96 +1,0 @@
-// Server-side rendering of the bits the Shaer clients render natively, so the
-// Klonkt web timeline looks the same: FEP-9098 custom emojis (`:shortcode:` →
-// image) in note content and display names, and the FEP-044f embedded quote
-// card. Pure + deterministic (no DB, no I/O), so it is unit-testable and cheap.
-
-const SHORTCODE = /:[A-Za-z0-9_+-]+:/g;
-
-const HTML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
-export function escapeHtml(s) {
-  return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]);
-}
-function escapeAttr(s) {
-  return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => HTML_ESCAPES[c]);
-}
-
-// Normalise either representation into a { ":shortcode:": url } map:
-//  - emoji_json: an array of Emoji tag objects [{ name, icon:{url} }]
-//  - author_emoji_json / reblog_emoji_json / quote.emojis: already a map.
-export function emojiMap(json) {
-  try {
-    const v = json == null ? null : (typeof json === 'string' ? JSON.parse(json) : json);
-    if (!v) return {};
-    if (Array.isArray(v)) {
-      const m = {};
-      for (const t of v) {
-        const icon = t && t.icon;
-        const url = icon && (icon.url || (Array.isArray(icon) && icon[0] && icon[0].url));
-        if (t && typeof t.name === 'string' && url) m[t.name] = url;
-      }
-      return m;
-    }
-    if (typeof v === 'object') {
-      const m = {};
-      for (const k of Object.keys(v)) if (typeof v[k] === 'string') m[k] = v[k];
-      return m;
-    }
-    return {};
-  } catch { return {}; }
-}
-
-function emojiImg(url, alt) {
-  return `<img class="emoji" src="${escapeAttr(url)}" alt="${escapeAttr(alt)}" title="${escapeAttr(alt)}" draggable="false" loading="lazy">`;
-}
-
-function substitute(text, map) {
-  return text.replace(SHORTCODE, (m) => (map[m] ? emojiImg(map[m], m) : m));
-}
-
-// Inject <img> for each known custom emoji into an already-sanitised HTML
-// fragment (note content). Substitutes only in text between tags (never inside
-// a tag or its attributes) and skips <code>/<pre>, mirroring the Shaer render.
-export function emojiHtml(html, json) {
-  const map = emojiMap(json);
-  if (!html || !Object.keys(map).length) return html || '';
-  let out = '';
-  let i = 0;
-  let code = 0;
-  while (i < html.length) {
-    if (html[i] === '<') {
-      const close = html.indexOf('>', i);
-      if (close < 0) { out += html.slice(i); break; }
-      const raw = html.slice(i + 1, close);
-      const name = raw.replace(/^\//, '').split(/[\s/>]/)[0].toLowerCase();
-      if (name === 'code' || name === 'pre') code = Math.max(0, code + (raw[0] === '/' ? -1 : 1));
-      out += html.slice(i, close + 1);   // copy the tag verbatim
-      i = close + 1;
-    } else {
-      const next = html.indexOf('<', i);
-      const end = next < 0 ? html.length : next;
-      const text = html.slice(i, end);
-      out += code > 0 ? text : substitute(text, map);
-      i = end;
-    }
-  }
-  return out;
-}
-
-// A plain-text display name with custom emojis → safe HTML. The name is HTML-
-// escaped first; shortcode characters ([A-Za-z0-9_+-]) survive escaping, so the
-// image substitution stays correct.
-export function emojiName(text, json) {
-  const esc = escapeHtml(text);
-  const map = emojiMap(json);
-  if (!Object.keys(map).length) return esc;
-  return substitute(esc, map);
-}
-
-// The resolved quoted-post snapshot Klonkt stored (quote_json), or null.
-export function parseQuote(json) {
-  try {
-    const q = json == null ? null : (typeof json === 'string' ? JSON.parse(json) : json);
-    return (q && typeof q === 'object' && q.url) ? q : null;
-  } catch { return null; }
-}
-
-export default { escapeHtml, emojiMap, emojiHtml, emojiName, parseQuote };
Index: src/services/OAuthService.js
===================================================================
--- src/services/OAuthService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,138 +1,0 @@
-/**
- * OAuthService — OAuth 2.0 for ActivityPub Client-to-Server (C2S).
- *
- * The AP spec recommends OAuth 2.0 bearer tokens for C2S; the actor document
- * advertises endpoints.oauthAuthorizationEndpoint / oauthTokenEndpoint, and
- * RFC 8414 (/.well-known/oauth-authorization-server) advertises the
- * registration endpoint. Design choices (v1):
- *   - PUBLIC clients only (native apps, RFC 8252): no client secrets,
- *     PKCE S256 is REQUIRED on the authorization-code flow.
- *   - A token is scoped to ONE user + ONE site (multi-site hub: the consent
- *     page picks the site). Scope string is informational ('c2s').
- *   - Tokens are stored hashed (sha256); codes are single-use, 10 min TTL.
- */
-
-import crypto from 'crypto';
-import db from '../config/database.js';
-
-const CODE_TTL_MS = 10 * 60 * 1000;
-
-const b64url = (buf) => buf.toString('base64url');
-const sha256 = (s) => crypto.createHash('sha256').update(s).digest();
-
-// Redirect URIs: https:// (web) or a custom scheme with a dot (reverse-DNS,
-// RFC 8252 §7.1, e.g. com.shaer.app:/callback). Plain http only for loopback.
-export function validRedirectUri(uri) {
-  try {
-    const u = new URL(uri);
-    if (u.protocol === 'https:') return true;
-    if (u.protocol === 'http:') return u.hostname === '127.0.0.1' || u.hostname === 'localhost' || u.hostname === '[::1]';
-    return /^[a-z0-9-]+(\.[a-z0-9-]+)+:$/i.test(u.protocol); // custom reverse-DNS scheme
-  } catch { return false; }
-}
-
-// RFC 7591 (subset): register a public client. Returns the stored metadata.
-export function registerClient({ client_name, redirect_uris }) {
-  const name = String(client_name || '').trim().slice(0, 120);
-  const uris = (Array.isArray(redirect_uris) ? redirect_uris : [redirect_uris]).filter(Boolean).map(String);
-  if (!name) return { error: 'invalid_client_metadata', error_description: 'client_name is required' };
-  if (!uris.length || !uris.every(validRedirectUri)) {
-    return { error: 'invalid_redirect_uri', error_description: 'redirect_uris must be https, loopback http, or a reverse-DNS custom scheme' };
-  }
-  const clientId = b64url(crypto.randomBytes(18));
-  db.prepare('INSERT INTO oauth_clients (client_id, client_name, redirect_uris) VALUES (?,?,?)')
-    .run(clientId, name, JSON.stringify(uris));
-  return {
-    client_id: clientId,
-    client_name: name,
-    redirect_uris: uris,
-    token_endpoint_auth_method: 'none', // public client: PKCE, no secret
-    grant_types: ['authorization_code'],
-    response_types: ['code'],
-  };
-}
-
-export function getClient(clientId) {
-  const row = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(String(clientId || ''));
-  if (!row) return null;
-  let uris = []; try { uris = JSON.parse(row.redirect_uris); } catch { /* corrupt row */ }
-  return { client_id: row.client_id, client_name: row.client_name, redirect_uris: uris };
-}
-
-// Authorization step (after user consent): mint a single-use code.
-export function createCode({ clientId, userId, siteSlug, redirectUri, codeChallenge, scope }) {
-  if (!codeChallenge || !/^[A-Za-z0-9_-]{43}$/.test(String(codeChallenge))) {
-    return { error: 'invalid_request', error_description: 'PKCE S256 code_challenge is required' };
-  }
-  const code = b64url(crypto.randomBytes(24));
-  db.prepare(`INSERT INTO oauth_codes (code, client_id, user_id, site_slug, redirect_uri, code_challenge, scope, expires_at)
-              VALUES (?,?,?,?,?,?,?,?)`)
-    .run(code, clientId, userId, siteSlug, redirectUri, codeChallenge, scope || 'c2s',
-         new Date(Date.now() + CODE_TTL_MS).toISOString());
-  return { code };
-}
-
-// Token step: exchange code + PKCE verifier for a bearer token.
-export function exchangeCode({ code, client_id, redirect_uri, code_verifier }) {
-  const row = db.prepare('SELECT * FROM oauth_codes WHERE code = ?').get(String(code || ''));
-  // Single use: delete immediately, whatever happens next (replay protection).
-  if (row) db.prepare('DELETE FROM oauth_codes WHERE code = ?').run(row.code);
-  if (!row) return { error: 'invalid_grant' };
-  if (Date.parse(row.expires_at) < Date.now()) return { error: 'invalid_grant', error_description: 'code expired' };
-  if (row.client_id !== String(client_id || '')) return { error: 'invalid_grant', error_description: 'client mismatch' };
-  if (row.redirect_uri !== String(redirect_uri || '')) return { error: 'invalid_grant', error_description: 'redirect_uri mismatch' };
-  const expected = b64url(sha256(String(code_verifier || '')));
-  if (expected !== row.code_challenge) return { error: 'invalid_grant', error_description: 'PKCE verification failed' };
-  const token = b64url(crypto.randomBytes(32));
-  db.prepare('INSERT INTO oauth_tokens (token_hash, client_id, user_id, site_slug, scope) VALUES (?,?,?,?,?)')
-    .run(b64url(sha256(token)), row.client_id, row.user_id, row.site_slug, row.scope);
-  return { access_token: token, token_type: 'Bearer', scope: row.scope };
-}
-
-// Resolve "Authorization: Bearer <token>" → { user, site } or null. The C2S
-// caller must additionally check the site matches the URL and permissions.
-export function verifyBearer(authHeader) {
-  const m = /^Bearer\s+([A-Za-z0-9_-]{20,})$/i.exec(String(authHeader || '').trim());
-  if (!m) return null;
-  const hash = b64url(sha256(m[1]));
-  const row = db.prepare('SELECT * FROM oauth_tokens WHERE token_hash = ?').get(hash);
-  if (!row) return null;
-  try { db.prepare('UPDATE oauth_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token_hash = ?').run(hash); } catch { /* non-fatal */ }
-  const user = db.prepare('SELECT * FROM users WHERE id = ?').get(row.user_id);
-  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(row.site_slug);
-  if (!user || !site) return null;
-  return { user, site, scope: row.scope, client_id: row.client_id };
-}
-
-export function revokeToken(token) {
-  try { db.prepare('DELETE FROM oauth_tokens WHERE token_hash = ?').run(b64url(sha256(String(token || '')))); } catch { /* ignore */ }
-}
-
-// The active authorizations (bearer tokens) a user has granted, with the app
-// name and the site each is scoped to. The bearer itself is never stored, so
-// revocation is keyed on token_hash: safe to render, you cannot derive the
-// token from its hash.
-export function listAuthorizations(userId) {
-  return db.prepare(`
-    SELECT t.token_hash, t.site_slug, t.scope, t.created_at, t.last_used_at, c.client_name
-    FROM oauth_tokens t
-    LEFT JOIN oauth_clients c ON c.client_id = t.client_id
-    WHERE t.user_id = ?
-    ORDER BY t.created_at DESC
-  `).all(String(userId || ''));
-}
-
-// Revoke one authorization, scoped to the owner so a user can only revoke their
-// own tokens. Returns true when a row was removed.
-export function revokeAuthorization(userId, tokenHash) {
-  try {
-    const r = db.prepare('DELETE FROM oauth_tokens WHERE token_hash = ? AND user_id = ?')
-      .run(String(tokenHash || ''), String(userId || ''));
-    return r.changes > 0;
-  } catch { return false; }
-}
-
-export default {
-  registerClient, getClient, createCode, exchangeCode, verifyBearer, revokeToken, validRedirectUri,
-  listAuthorizations, revokeAuthorization,
-};
Index: src/services/OgImageService.js
===================================================================
--- src/services/OgImageService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,119 +1,0 @@
-/**
- * OgImageService — generates a themed Open Graph card (1200x630 PNG) per site,
- * derived from the site's palette + accent, so every site has a branded social
- * preview even without uploading one. SVG is hand-built and rasterized with
- * @resvg/resvg-js. Result is cached on disk (keyed by the theming inputs).
- *
- * Graceful: if @resvg/resvg-js can't load (exotic platform), ogImageFor()
- * returns null and the caller falls back to no/other og:image — never throws.
- */
-import fs from 'fs';
-import path from 'path';
-import crypto from 'crypto';
-import { fileURLToPath } from 'url';
-import { createRequire } from 'module';
-import ThemeService from './ThemeService.js';
-
-const require = createRequire(import.meta.url);
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
-
-const FONT = path.join(__dirname, '..', 'assets', 'fonts', 'fraunces-og.ttf');
-const DATA_DIR = path.dirname(process.env.DATABASE_PATH || './storage/database.sqlite');
-const CACHE_DIR = path.join(DATA_DIR, 'og');
-const TEMPLATE_VERSION = 1; // bump to invalidate all cached cards after a design change
-
-let _Resvg = null, _tried = false;
-function getResvg() {
-  if (_tried) return _Resvg;
-  _tried = true;
-  try { _Resvg = require('@resvg/resvg-js').Resvg; } catch { _Resvg = null; }
-  return _Resvg;
-}
-
-// ── tiny colour helpers ───────────────────────────────────────────
-function hx(h) {
-  h = String(h || '').replace('#', '');
-  if (h.length === 3) h = h.split('').map((c) => c + c).join('');
-  return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) || 0);
-}
-function rgb(a) {
-  return '#' + a.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('');
-}
-function mix(a, b, t) { const A = hx(a), B = hx(b); return rgb(A.map((v, i) => v + (B[i] - v) * t)); }
-function esc(s) { return String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c])); }
-
-function buildSvg(site, palette, accent, theme) {
-  const _p = ThemeService.PALETTES[palette] || ThemeService.PALETTES.klonkt;
-  const pal = _p[theme] || _p.dark;
-  const paper = pal.paper, ink = pal.ink;
-  const paper2 = mix(paper, ink, 0.08);
-  const muted = mix(ink, paper, 0.42);
-
-  let title = (site.title || 'Klonkt').trim();
-  let tag = (site.tagline || site.description || '').trim();
-  if (title.length > 38) title = title.slice(0, 37) + '…';
-  if (tag.length > 74) tag = tag.slice(0, 73) + '…';
-  const tsize = title.length <= 12 ? 100 : title.length <= 20 ? 82 : title.length <= 30 ? 64 : 54;
-
-  return `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630">
-  <defs>
-    <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
-      <stop offset="0" stop-color="${paper}"/><stop offset="1" stop-color="${paper2}"/>
-    </linearGradient>
-    <radialGradient id="glow" cx="0.85" cy="0.12" r="0.7">
-      <stop offset="0" stop-color="${accent}" stop-opacity="0.20"/>
-      <stop offset="1" stop-color="${accent}" stop-opacity="0"/>
-    </radialGradient>
-  </defs>
-  <rect width="1200" height="630" fill="url(#bg)"/>
-  <rect width="1200" height="630" fill="url(#glow)"/>
-  <rect x="0" y="0" width="14" height="630" fill="${accent}"/>
-  <g transform="translate(96,232)">
-    <rect x="0"  y="14" width="11" height="34" rx="3" fill="${accent}"/>
-    <rect x="18" y="0"  width="11" height="48" rx="3" fill="${accent}"/>
-    <rect x="36" y="22" width="11" height="26" rx="3" fill="${accent}"/>
-    <rect x="54" y="8"  width="11" height="40" rx="3" fill="${accent}"/>
-  </g>
-  <text x="96" y="400" font-size="${tsize}" fill="${ink}">${esc(title)}</text>
-  ${tag ? `<text x="98" y="462" font-size="34" fill="${muted}">${esc(tag)}</text>` : ''}
-  <text x="96" y="566" font-size="30" fill="${accent}">klonkt</text>
-</svg>`;
-}
-
-/**
- * Returns a PNG Buffer of the site's OG card (cached), or null if generation
- * isn't possible. `site` needs: slug, title, palette, accent, tagline/description.
- */
-export function ogImageFor(site) {
-  const Resvg = getResvg();
-  if (!Resvg || !site || !site.slug) return null;
-
-  const palette = ThemeService.PALETTES[site.palette] ? site.palette : 'klonkt';
-  // Card variant: an explicit SEO override (og_theme) wins; else follow the site's "default
-  // theme for new visitors" (theme_override) — light when the site is set to Light, otherwise
-  // dark (an OG image is static, so Auto/Dark → dark).
-  const theme = (site.og_theme === 'light' || site.og_theme === 'dark')
-    ? site.og_theme
-    : (site.theme_override === 'light' ? 'light' : 'dark');
-  const accent = site.accent || ((ThemeService.PALETTES[palette] || ThemeService.PALETTES.klonkt)[theme] || ThemeService.PALETTES.klonkt.dark).accent;
-  const key = crypto.createHash('sha1')
-    .update([TEMPLATE_VERSION, site.slug, palette, accent, theme, site.title || '', site.tagline || site.description || ''].join('\x1f'))
-    .digest('hex').slice(0, 16);
-  const file = path.join(CACHE_DIR, key + '.png');
-
-  try { return fs.readFileSync(file); } catch { /* not cached yet */ }
-
-  try {
-    const svg = buildSvg(site, palette, accent, theme);
-    const png = new Resvg(svg, {
-      font: { fontFiles: [FONT], loadSystemFonts: false },
-      fitTo: { mode: 'width', value: 1200 },
-    }).render().asPng();
-    try { fs.mkdirSync(CACHE_DIR, { recursive: true }); fs.writeFileSync(file, png); } catch { /* cache best-effort */ }
-    return png;
-  } catch {
-    return null;
-  }
-}
-
-export default { ogImageFor };
Index: src/services/OpenWebAuthService.js
===================================================================
--- src/services/OpenWebAuthService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,372 +1,0 @@
-/**
- * OpenWebAuth (FEP-61cf) — de TARGET-kant.
- *
- * Waarom dit bestaat: `fan_only` betekent "mijn volgers op de fediverse", maar
- * de poort vroeg om een KLONKT-ACCOUNT. Dat is de verkeerde vraag: precies de
- * mensen voor wie de poort openstaat -- volgers elders -- konden er niet door,
- * en wie er wel door kon had meestal niets met volgen te maken. Hiermee kan een
- * bezoeker bewijzen dat hij @iemand@ergens is, zonder hier een account, een
- * wachtwoord of een cookie van een derde partij.
- *
- * WIJ ZIJN DE TARGET INSTANCE, nooit de home instance. Dat is de prettige helft:
- * de home instance heeft de prive-sleutel nodig (om te ondertekenen en om ons
- * token te ontsleutelen), wij hebben alleen publieke sleutels nodig. Er staat
- * hier dus geen geheim van iemand anders, en we kunnen ook niemands identiteit
- * uitgeven. Het spiegelbeeld (Klonkt-gebruikers laten inloggen OP andere sites,
- * de /magic-kant) is bewust NIET gebouwd: dat is een andere functie.
- *
- * De stroom, met de FEP-stappen erbij:
- *   1. bezoeker geeft zijn adres        -> wij webfingeren hem, vinden zijn
- *                                          redirect-endpoint, sturen hem daarheen
- *   2. zijn server controleert hem      -> en vraagt ONS om een token
- *   3. wij verifieren die ondertekende  -> token terug, versleuteld met ZIJN
- *      aanvraag                            publieke sleutel
- *   4. zijn server ontsleutelt          -> stuurt hem terug met ?owt=<token>
- *   5. wij wisselen het token in        -> nu weten we wie hij is
- *
- * DRIE DINGEN DIE DE FEP ALS AANVAL BESCHRIJFT, en die hieronder staan omdat ze
- * anders precies de fout worden die je niet ziet:
- *
- *  - IMPERSONATIE. `?zid=` mag NOOIT iemands identiteit bepalen; alleen het
- *    ingewisselde `?owt=` telt. Mallory kan een link maken met zid=bob@elders,
- *    en komt dan terug met een token dat MALLORY zegt. Wie zid gelooft, laat
- *    Mallory als Bob binnen.
- *  - OPEN REDIRECT. Het redirect-endpoint dat we uit webfinger halen moet
- *    dezelfde host hebben als het adres dat de bezoeker intypte, anders sturen
- *    wij bezoekers naar waar een vreemde maar wil.
- *  - DoS. Tokens vervallen in minuten en gaan na een keer gebruiken weg.
- */
-import crypto from 'crypto';
-import db from '../config/database.js';
-
-/** Kort, want tussen stap 3 en 5 zit alleen een redirect. De FEP noemt "a couple of minutes". */
-export const TOKEN_TTL_MS = 3 * 60 * 1000;
-
-/** rel-waarden uit de FEP. Letterlijk, want hier hangt de vindbaarheid aan. */
-export const REL_TOKEN = 'http://purl.org/openwebauth/v1';
-export const REL_REDIRECT = 'http://purl.org/openwebauth/v1#redirect';
-
-// ── tokens ────────────────────────────────────────────────────────────────
-
-/** Alles wat over tijd is weg. Draait bij elke uitgifte en elke inwisseling. */
-export function sweepTokens(now = Date.now()) {
-  db.prepare('DELETE FROM owa_tokens WHERE created_at < ?').run(now - TOKEN_TTL_MS);
-}
-
-/**
- * Stap 3: een token voor deze actor, opgeslagen zodat we hem straks herkennen.
- * URL-veilig, want hij reist als query-parameter terug.
- */
-export function issueToken(actorUri, now = Date.now()) {
-  sweepTokens(now);
-  const token = crypto.randomBytes(32).toString('base64url');
-  db.prepare('INSERT INTO owa_tokens (token, actor_uri, created_at) VALUES (?,?,?)')
-    .run(token, String(actorUri), now);
-  return token;
-}
-
-/**
- * Stap 5: eenmalig inwisselen. Geeft de actor terug, of null.
- *
- * Het verwijderen gebeurt ALTIJD, ook als het token te oud bleek: een token dat
- * eenmaal is aangeboden mag nooit een tweede kans krijgen.
- */
-export function redeemToken(token, now = Date.now()) {
-  const t = String(token || '');
-  if (!t) return null;
-  const row = db.prepare('SELECT actor_uri, created_at FROM owa_tokens WHERE token = ?').get(t);
-  if (row) db.prepare('DELETE FROM owa_tokens WHERE token = ?').run(t);
-  sweepTokens(now);
-  if (!row) return null;
-  if (now - row.created_at > TOKEN_TTL_MS) return null;
-  return row.actor_uri;
-}
-
-/**
- * Het token versleuteld met de PUBLIEKE sleutel van de actor, zodat alleen zijn
- * server het kan lezen. PKCS#1 v1.5 en base64url zonder '=' staan zo in de FEP;
- * dat is geen smaak maar interop met Hubzilla en (streams).
- */
-export function encryptTokenFor(token, publicKeyPem) {
-  const buf = crypto.publicEncrypt(
-    { key: publicKeyPem, padding: crypto.constants.RSA_PKCS1_PADDING },
-    Buffer.from(String(token), 'utf8'),
-  );
-  return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
-}
-
-// ── ontdekken waar de bezoeker vandaan komt ───────────────────────────────
-
-/** `@iemand@ergens.nl`, `iemand@ergens.nl`, `acct:iemand@ergens.nl` -> {user, host}. */
-export function parseHandle(input) {
-  const m = String(input || '').trim().replace(/^acct:/i, '').replace(/^@/, '')
-    .match(/^([^@\s/]+)@([^@\s/]+)$/);
-  if (!m) return null;
-  const host = m[2].toLowerCase();
-  if (!/^[a-z0-9.-]+(:\d+)?$/i.test(host)) return null;
-  return { user: m[1], host, acct: `${m[1]}@${host}` };
-}
-
-/**
- * Stap 1: waar stuurt deze bezoeker zich heen om zich te bewijzen?
- *
- * De FEP: nieuwe implementaties horen te webfingeren, oude hard-coden /magic.
- * We doen het eerste en vallen terug op het tweede -- die terugval is veilig
- * omdat hij per constructie op DEZELFDE host ligt.
- *
- * En hier staat de open-redirect-controle: wat webfinger ook teruggeeft, het
- * moet de host zijn van het adres dat de bezoeker zelf intypte. Zonder die
- * regel wordt dit formulier een doorgeefluik naar elke gewenste URL.
- */
-export async function discoverRedirectEndpoint(handle, { fetchImpl = fetch } = {}) {
-  const h = parseHandle(handle);
-  if (!h) return null;
-  const url = `https://${h.host}/.well-known/webfinger?resource=${encodeURIComponent('acct:' + h.acct)}`;
-  let href = null;
-  try {
-    const r = await fetchImpl(url, { headers: { accept: 'application/jrd+json, application/json' } });
-    if (r.ok) {
-      const jrd = await r.json();
-      const link = (jrd.links || []).find((l) => l && l.rel === REL_REDIRECT && l.href);
-      if (link) href = link.href;
-    }
-  } catch { /* geen webfinger: hieronder de terugval */ }
-  if (!href) href = `https://${h.host}/magic`;
-  try {
-    if (new URL(href).host.toLowerCase() !== h.host) return null;   // open redirect
-  } catch { return null; }
-  return { endpoint: href, handle: h };
-}
-
-/** `bdest`: de terugkeer-URL als hex, zo staat het in de FEP. */
-export function toBdest(url) {
-  return Buffer.from(String(url), 'utf8').toString('hex');
-}
-
-/**
- * De URL waar we de bezoeker heen sturen.
- *
- * De terugkeer-URL moet BINNEN onze eigen origin liggen -- en het liefst binnen
- * de PWA-scope (siteUrlBase), anders komt iemand die de site op zijn
- * beginscherm heeft na het inloggen terecht in een losse browsertab terwijl de
- * app uitgelogd blijft. Dat ziet eruit als "inloggen werkt niet" en is het niet.
- */
-export function buildRedirect(endpoint, returnUrl) {
-  const u = new URL(endpoint);
-  u.searchParams.set('owa', '1');
-  u.searchParams.set('bdest', toBdest(returnUrl));
-  return u.toString();
-}
-
-// ── de HOME-kant: onze gebruiker bewijst zich elders ──────────────────────
-//
-// Hier zijn de rollen omgedraaid. Wij hebben nu de prive-sleutel nodig -- om te
-// ondertekenen en om het token te ontsleutelen -- en dat is precies waarom
-// alleen een echte instance deze kant kan spelen.
-
-/** `bdest` terug naar een URL. Hex in, URL uit; ongeldig = null. */
-export function fromBdest(hex) {
-  const h = String(hex || '');
-  if (!/^[0-9a-f]+$/i.test(h) || h.length % 2) return null;
-  try {
-    const u = new URL(Buffer.from(h, 'hex').toString('utf8'));
-    if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
-    return u;
-  } catch { return null; }
-}
-
-/**
- * Het token-endpoint van de doelsite, gevonden via webfinger op zijn WORTEL.
- *
- * En meteen de open-redirect-verdediging van deze kant: het gevonden endpoint
- * moet dezelfde origin hebben als `bdest`. De FEP zegt het met zoveel woorden --
- * lukt de ontdekking niet, of wijst hij ergens anders heen, dan sturen we de
- * browser NIET naar bdest maar geven we een fout. Anders is /magic het
- * doorgeefluik.
- */
-export async function discoverTokenEndpoint(bdestUrl, { fetchImpl = fetch } = {}) {
-  let origin;
-  try { origin = new URL(bdestUrl).origin; } catch { return null; }
-  const url = `${origin}/.well-known/webfinger?resource=${encodeURIComponent(origin + '/')}`;
-  try {
-    const r = await fetchImpl(url, { headers: { accept: 'application/jrd+json, application/json' } });
-    if (!r.ok) return null;
-    const jrd = await r.json();
-    const link = (jrd.links || []).find((l) => l && l.rel === REL_TOKEN && l.href);
-    if (!link) return null;
-    if (new URL(link.href).origin !== origin) return null;   // open redirect
-    return link.href;
-  } catch { return null; }
-}
-
-/**
- * Het token ophalen bij de doelsite, ondertekend namens onze actor.
- *
- * De handtekening gaat in `Authorization: Signature ...` -- zo schrijft de FEP
- * het voor, en niet in de `Signature`-header die de rest van de fediverse
- * gebruikt. Plus `X-Open-Web-Auth` met willekeur erin: de doelsite doet er
- * niets mee, het voegt alleen entropie toe aan wat we ondertekenen.
- */
-export async function requestToken(endpoint, { keyId, privatePem, fetchImpl = fetch } = {}) {
-  const u = new URL(endpoint);
-  const date = new Date().toUTCString();
-  const nonce = crypto.randomBytes(16).toString('hex');
-  const target = `${u.pathname}${u.search || ''}`;
-  const signingString = [
-    `(request-target): get ${target}`,
-    `host: ${u.host}`,
-    `date: ${date}`,
-    `x-open-web-auth: ${nonce}`,
-  ].join('\n');
-  const signature = crypto.sign('sha256', Buffer.from(signingString), privatePem).toString('base64');
-  const headers = {
-    Accept: 'application/json',
-    Date: date,
-    'X-Open-Web-Auth': nonce,
-    Authorization: `Signature keyId="${keyId}",algorithm="rsa-sha256",headers="(request-target) host date x-open-web-auth",signature="${signature}"`,
-  };
-  const r = await fetchImpl(endpoint, { headers });
-  if (!r.ok) return null;
-  const j = await r.json();
-  if (!j || j.success !== true || !j.encrypted_token) return null;
-  return String(j.encrypted_token);
-}
-
-/**
- * Een deterministische nep-uitkomst, afgeleid uit de ciphertext en onze eigen
- * sleutel. Dit is de kern van implicit rejection: bij ongeldige padding geven we
- * GEEN fout maar een waarde, zodat "klopte de padding" nergens af te lezen is.
- *
- * DETERMINISTISCH, en dat is geen detail. Zou dit verse willekeur zijn, dan
- * geeft dezelfde ciphertext twee keer aanbieden twee verschillende antwoorden --
- * en juist dat verschil is het onderscheid dat we wilden verbergen. Zo doen TLS
- * en OpenSSL 3.2 het ook: afgeleid uit sleutel + ciphertext, dus stabiel bij
- * herhaling en onvoorspelbaar voor wie de sleutel niet heeft.
- *
- * Geëxporteerd omdat die eigenschap toetsbaar moet zijn; buiten de tests heeft
- * niemand hem nodig.
- *
- * EERLIJK OVER WAT DIT WEL EN NIET DRAAGT (gemeten 19-8): haal je hem weg, dan
- * blijft de suite groen. De andere tak geeft dan een LEGE string terug, en die
- * sneuvelt net zo goed op de tekenset-controle hieronder -- "werpt niet" en
- * "levert geen token" zijn dus al gedekt zonder deze functie. Wat hij toevoegt
- * is dat ALLE faalwegen dezelfde vorm teruggeven: verkeerde sleutel, verkeerde
- * lengte, kapotte base64, ongeldige padding. Een lege string is een verklikker
- * voor wie ooit naar de rauwe waarde kijkt in plaats van naar het eindoordeel;
- * afgeleide bytes zijn dat niet. Zo doen TLS en OpenSSL 3.2 het ook.
- */
-export function _nepUitkomst(privatePem, ct) {
-  const geheim = crypto.createHash('sha256').update(String(privatePem)).digest();
-  return crypto.createHmac('sha256', geheim).update(ct).digest().toString('latin1');
-}
-
-/**
- * PKCS#1 v1.5 zelf uitpakken (EME-PKCS1-v1_5: 00 02 PS 00 M).
- *
- * WAAROM ZELF: Node weigert `privateDecrypt` met RSA_PKCS1_PADDING sinds de
- * mitigatie voor CVE-2023-46809 (Marvin). De revert-vlag bestaat alleen op de
- * lijnen 18/20/21 -- Node 22+ heeft hem nooit gehad, en 20 is sinds 30 april
- * 2026 EOL. Er is dus geen weg terug; zie shaer-r15.
- *
- * OpenWebAuth (FEP-61cf) schrijft v1.5 voor, dus overstappen op OAEP repareert
- * de fout en breekt de interop met Hubzilla. Blijft over: `RSA_NO_PADDING` en
- * het omhulsel er zelf afhalen -- precies het stuk dat de CVE veroorzaakte, dus
- * met de zorg die daarbij hoort.
- *
- * GEEN VROEGE UITGANG EN GEEN WORP. De scan loopt altijd het hele blok af en
- * beide takken doen hetzelfde werk. Dat is geen echte constant-time -- die
- * krijg je in JavaScript met JIT en GC niet -- maar het haalt wel het
- * waarneembare verschil weg. Wat de aanval hier echt begrenst is de teller op
- * /magic: een orakel heeft honderdduizenden pogingen nodig.
- */
-function pakUit(blok, privatePem, ct) {
-  const k = blok.length;
-  // Kop: 00 02. Als getal uitrekenen, niet als vertakking.
-  let goed = ((blok[0] === 0x00) & (blok[1] === 0x02));
-  // Eerste nulbyte vanaf 2 zoeken ZONDER de lus te verlaten.
-  let sep = -1;
-  for (let i = 2; i < k; i++) {
-    const isNul = blok[i] === 0x00 ? 1 : 0;
-    const nogNiet = sep === -1 ? 1 : 0;
-    sep = sep + (isNul & nogNiet) * (i - sep);
-  }
-  // PS moet minstens 8 bytes zijn (RFC 8017), dus de scheider ligt op >= 10.
-  goed = goed & (sep >= 10 ? 1 : 0) & (sep < k ? 1 : 0);
-  const echt = blok.subarray(goed ? sep + 1 : k).toString('utf8');
-  const nep = _nepUitkomst(privatePem, ct);
-  return goed ? echt : nep;
-}
-
-/** Het token uitpakken met onze eigen prive-sleutel. */
-export function decryptToken(encrypted, privatePem) {
-  const b64 = String(encrypted || '').replace(/-/g, '+').replace(/_/g, '/');
-  const ct = Buffer.from(b64, 'base64');
-  let k = 0;
-  try { k = crypto.createPublicKey(privatePem).asymmetricKeyDetails.modulusLength / 8; } catch { k = 0; }
-
-  // Een blok van de verkeerde lengte zegt niets over de sleutel, maar het zou
-  // wel werpen -- en een worp is precies het signaal dat we kwijt willen. Dus
-  // dezelfde weg als een ongeldige padding.
-  let blok = null;
-  if (k && ct.length === k) {
-    try {
-      blok = crypto.privateDecrypt({ key: privatePem, padding: crypto.constants.RSA_NO_PADDING }, ct);
-    } catch { blok = null; }
-  }
-  const t = (blok && blok.length === k) ? pakUit(blok, privatePem, ct) : _nepUitkomst(privatePem, ct);
-
-  // Een token is URL-veilige tekst. Wat hierboven uit een mislukking komt is
-  // afgeleide onzin, en die hoort hier te stranden in plaats van als token de
-  // wereld in te gaan.
-  //
-  // ALLEBEI de voorwaarden doen werk, en dat is gemeten met 300 vreemde sleutels:
-  //  - de TEKENSET vangt vrijwel alles. Van die 300 was er geen enkele die
-  //    volledig uit URL-veilige tekens bestond.
-  //  - de ONDERGRENS vangt de rest. De onzin heeft een willekeurige lengte, en
-  //    bij een kort stukje is "toevallig allemaal URL-veilig" niet meer
-  //    verwaarloosbaar: per byte is die kans ruwweg een kwart.
-  // Zestien is daarmee geen rond getal maar een grens die iets doet. Een echte
-  // implementatie zit er ruim boven (de onze: 43 tekens).
-  return /^[A-Za-z0-9._~-]{16,512}$/.test(t) ? t : null;
-}
-
-// ── wie is er binnen ──────────────────────────────────────────────────────
-
-/** De actor die deze sessie bewees te zijn, of null. */
-export function guestActor(req) {
-  const g = req && req.session && req.session.owa;
-  return (g && typeof g.actor === 'string' && g.actor) ? g.actor : null;
-}
-
-/** Volgt deze actor deze site? Dat is de vraag die `fan_only` altijd al stelde. */
-export function isFollowerOf(slug, actorUri) {
-  if (!slug || !actorUri) return false;
-  const row = db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND actor_uri = ? LIMIT 1')
-    .get(String(slug), String(actorUri));
-  return !!row;
-}
-
-/**
- * Alles wat een poort over deze bezoeker moet weten, op één plek.
- *
- * Bewust hier en niet in PostAccessService: die module beslist en raakt de
- * database niet aan. Deze haalt op, die beslist.
- */
-export function viewerFor(req, site, extra = {}) {
-  const actor = guestActor(req);
-  return {
-    user: (req && req.session && req.session.user) || null,
-    site: site || null,
-    fediActor: actor,
-    isFollower: actor && site ? isFollowerOf(site.slug, actor) : false,
-    ...extra,
-  };
-}
-
-export default {
-  TOKEN_TTL_MS, REL_TOKEN, REL_REDIRECT,
-  sweepTokens, issueToken, redeemToken, encryptTokenFor,
-  parseHandle, discoverRedirectEndpoint, toBdest, buildRedirect,
-  guestActor, isFollowerOf, viewerFor,
-  fromBdest, discoverTokenEndpoint, requestToken, decryptToken,
-};
Index: src/services/PaidPatreonService.js
===================================================================
--- src/services/PaidPatreonService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,232 +1,0 @@
-// Paid posts (klonkt-demo-aki) slice 1: the site owner's own Patreon campaign.
-// Stores client id/secret + the creator access/refresh token (encrypted) and a
-// default price. Separate from PatreonService, which is Klonkt Premium's
-// instance-level license flow and stays untouched.
-import db from '../config/database.js';
-import { encrypt, decrypt, cryptoBoxReady } from './CryptoBox.js';
-
-const TOKEN_URL = 'https://www.patreon.com/api/oauth2/token';
-
-// The owner's config, secrets decrypted. Returns null when unconfigured.
-export function getOwnerConfig(siteId) {
-  const row = db.prepare('SELECT * FROM paid_patreon WHERE site_id = ?').get(siteId);
-  if (!row) return null;
-  return {
-    siteId: row.site_id,
-    clientId: row.client_id || null,
-    clientSecret: row.client_secret_enc ? safeDecrypt(row.client_secret_enc) : null,
-    campaignId: row.campaign_id || null,
-    accessToken: row.access_token_enc ? safeDecrypt(row.access_token_enc) : null,
-    refreshToken: row.refresh_token_enc ? safeDecrypt(row.refresh_token_enc) : null,
-    tokenExp: row.token_exp || 0,
-    defaultMinCents: row.default_min_cents || 0,
-    patreonUrl: row.patreon_url || null,
-  };
-}
-
-// Non-secret status for the admin screen (never returns tokens/secret).
-export function ownerStatus(siteId) {
-  const c = getOwnerConfig(siteId);
-  if (!c) return { configured: false, connected: false, defaultMinCents: 0 };
-  return {
-    configured: !!(c.clientId && c.clientSecret),
-    connected: !!(c.accessToken && c.campaignId),
-    clientId: c.clientId || null,
-    campaignId: c.campaignId || null,
-    defaultMinCents: c.defaultMinCents || 0,
-    tokenExp: c.tokenExp || 0,
-    hasSecret: !!c.clientSecret,
-    patreonUrl: c.patreonUrl || null,
-  };
-}
-
-// The owner's public Patreon page, for the "Word supporter" link. Null when unset.
-export function patreonUrl(siteId) {
-  const c = getOwnerConfig(siteId);
-  return c && c.patreonUrl ? c.patreonUrl : null;
-}
-
-// Upsert. Only overwrites secret/token fields when a new value is provided, so
-// the admin form can be re-saved without re-pasting the secret.
-export function saveOwnerConfig(siteId, patch) {
-  if (!cryptoBoxReady()) throw new Error('encryption key unavailable: cannot store Patreon secrets');
-  const cur = getOwnerConfig(siteId) || {};
-  const merged = {
-    clientId: patch.clientId ?? cur.clientId ?? null,
-    clientSecret: patch.clientSecret ?? cur.clientSecret ?? null,
-    campaignId: patch.campaignId ?? cur.campaignId ?? null,
-    accessToken: patch.accessToken ?? cur.accessToken ?? null,
-    refreshToken: patch.refreshToken ?? cur.refreshToken ?? null,
-    tokenExp: patch.tokenExp ?? cur.tokenExp ?? 0,
-    defaultMinCents: patch.defaultMinCents ?? cur.defaultMinCents ?? 0,
-    // undefined = keep (e.g. token refresh doesn't touch it); null/'' = clear.
-    patreonUrl: patch.patreonUrl !== undefined ? (patch.patreonUrl || null) : (cur.patreonUrl ?? null),
-  };
-  db.prepare(`INSERT INTO paid_patreon
-      (site_id, client_id, client_secret_enc, campaign_id, access_token_enc, refresh_token_enc, token_exp, default_min_cents, patreon_url, updated_at)
-      VALUES (?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)
-    ON CONFLICT(site_id) DO UPDATE SET
-      client_id=excluded.client_id, client_secret_enc=excluded.client_secret_enc,
-      campaign_id=excluded.campaign_id, access_token_enc=excluded.access_token_enc,
-      refresh_token_enc=excluded.refresh_token_enc, token_exp=excluded.token_exp,
-      default_min_cents=excluded.default_min_cents, patreon_url=excluded.patreon_url, updated_at=CURRENT_TIMESTAMP`)
-    .run(
-      siteId,
-      merged.clientId,
-      merged.clientSecret != null ? encrypt(merged.clientSecret) : null,
-      merged.campaignId,
-      merged.accessToken != null ? encrypt(merged.accessToken) : null,
-      merged.refreshToken != null ? encrypt(merged.refreshToken) : null,
-      merged.tokenExp || 0,
-      Math.max(0, parseInt(merged.defaultMinCents, 10) || 0),
-      merged.patreonUrl || null,
-    );
-}
-
-export function disconnect(siteId) {
-  db.prepare('DELETE FROM paid_patreon WHERE site_id = ?').run(siteId);
-}
-
-export function defaultMinCents(siteId) {
-  const row = db.prepare('SELECT default_min_cents FROM paid_patreon WHERE site_id = ?').get(siteId);
-  return row ? (row.default_min_cents || 0) : 0;
-}
-
-// True when the stored creator token is missing or within `skewSeconds` of exp.
-export function needsRefresh(siteId, skewSeconds = 3600) {
-  const c = getOwnerConfig(siteId);
-  if (!c || !c.refreshToken) return false;
-  return !c.accessToken || (c.tokenExp || 0) <= (Math.floor(Date.now() / 1000) + skewSeconds);
-}
-
-// Refresh the creator token via Patreon. Returns true on success. `fetchImpl`
-// is injectable for tests; defaults to global fetch.
-export async function refreshCreatorToken(siteId, fetchImpl = fetch) {
-  const c = getOwnerConfig(siteId);
-  if (!c || !c.clientId || !c.clientSecret || !c.refreshToken) return false;
-  const body = new URLSearchParams({
-    grant_type: 'refresh_token',
-    refresh_token: c.refreshToken,
-    client_id: c.clientId,
-    client_secret: c.clientSecret,
-  });
-  const res = await fetchImpl(TOKEN_URL, {
-    method: 'POST',
-    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
-    body: body.toString(),
-  });
-  if (!res.ok) return false;
-  const j = await res.json();
-  if (!j || !j.access_token) return false;
-  saveOwnerConfig(siteId, {
-    accessToken: j.access_token,
-    refreshToken: j.refresh_token || c.refreshToken,
-    tokenExp: Math.floor(Date.now() / 1000) + (parseInt(j.expires_in, 10) || 0),
-  });
-  return true;
-}
-
-// A valid creator access token, refreshing first if it is stale. Null when the
-// owner has not connected. Used by the patron verify path (slice 3).
-export async function creatorAccessToken(siteId, fetchImpl = fetch) {
-  if (needsRefresh(siteId)) { try { await refreshCreatorToken(siteId, fetchImpl); } catch { /* fall through */ } }
-  const c = getOwnerConfig(siteId);
-  return c && c.accessToken ? c.accessToken : null;
-}
-
-// Pure: pick the owner's-campaign membership out of a Patreon
-// identity?include=memberships.campaign response (JSON:API). Returns
-// { status, cents } or null.
-//
-// STRICT match on campaignId only. Patreon's /identity returns ALL of the
-// visitor's memberships across every creator they back (verified: a tester had
-// 12), NOT just this creator's, so any fallback would grant access to someone
-// who backs a DIFFERENT creator. The campaignId must therefore be the owner's
-// real campaign; verifyPatron auto-derives it from the creator token so a
-// mistyped admin value can't lock real patrons out.
-export function pickCampaignMembership(identity, campaignId) {
-  if (!campaignId) return null;
-  const inc = (identity && identity.included) || [];
-  for (const it of inc) {
-    if (it.type !== 'member') continue;
-    const camp = it.relationships && it.relationships.campaign && it.relationships.campaign.data;
-    if (!camp || String(camp.id) !== String(campaignId)) continue;
-    const a = it.attributes || {};
-    return { status: a.patron_status || null, cents: a.currently_entitled_amount_cents || 0 };
-  }
-  return null;
-}
-
-// The campaign id owned by the creator token (i.e. the site owner's OWN
-// campaign). This is authoritative: it removes the "typed the wrong campaign_id"
-// failure mode. Null if there's no valid creator token or the call fails.
-export async function fetchOwnerCampaignId(siteId, fetchImpl = fetch) {
-  const token = await creatorAccessToken(siteId, fetchImpl).catch(() => null);
-  if (!token) return null;
-  const res = await fetchImpl('https://www.patreon.com/api/oauth2/v2/campaigns', {
-    headers: { Authorization: `Bearer ${token}` },
-  }).catch(() => null);
-  if (!res || !res.ok) return null;
-  const j = await res.json().catch(() => null);
-  const id = j && j.data && j.data[0] && j.data[0].id;
-  return id ? String(id) : null;
-}
-
-// Exchange a patron's auth code and read their membership of the owner's
-// campaign. Returns { status, cents, diag } (status null = not a patron); the
-// `diag` string is a NON-identifying breadcrumb (campaign ids + status + cents)
-// so a stuck owner can see why. Returns null only on hard misconfig. The patron
-// token is used once and discarded here: nothing identifying is stored.
-export async function verifyPatron(siteId, code, redirectUri, fetchImpl = fetch) {
-  const c = getOwnerConfig(siteId);
-  if (!c || !c.clientId || !c.clientSecret) return null;
-  const none = (diag) => ({ status: null, cents: 0, diag });
-  let tokenRes;
-  try {
-    tokenRes = await fetchImpl(TOKEN_URL, {
-      method: 'POST',
-      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
-      body: new URLSearchParams({
-        grant_type: 'authorization_code', code,
-        client_id: c.clientId, client_secret: c.clientSecret, redirect_uri: redirectUri,
-      }).toString(),
-    });
-  } catch { return none('token_fetch_error'); }
-  if (!tokenRes.ok) return none(`token_http_${tokenRes.status}`);
-  const tok = await tokenRes.json();
-  if (!tok || !tok.access_token) return none('no_access_token');
-  const url = 'https://www.patreon.com/api/oauth2/v2/identity'
-    + '?include=memberships.campaign'
-    + '&fields%5Bmember%5D=patron_status,currently_entitled_amount_cents';
-  const idRes = await fetchImpl(url, { headers: { Authorization: `Bearer ${tok.access_token}` } });
-  if (!idRes.ok) return none(`identity_http_${idRes.status}`);
-  const identity = await idRes.json();
-  // Authoritative campaign id: the one owned by the creator token. Beats a
-  // mistyped admin value. Self-heal the stored config when they differ.
-  const ownerCampaign = await fetchOwnerCampaignId(siteId, fetchImpl).catch(() => null);
-  const campaignId = ownerCampaign || c.campaignId;
-  if (ownerCampaign && String(ownerCampaign) !== String(c.campaignId)) {
-    try { saveOwnerConfig(siteId, { campaignId: ownerCampaign }); } catch { /* non-fatal */ }
-  }
-  const membership = pickCampaignMembership(identity, campaignId);   // token goes out of scope, discarded
-  const seen = ((identity && identity.included) || [])
-    .filter((it) => it.type === 'member')
-    .map((it) => {
-      const camp = it.relationships && it.relationships.campaign && it.relationships.campaign.data;
-      const a = it.attributes || {};
-      return `${camp ? camp.id : '?'}:${a.patron_status || 'null'}:${a.currently_entitled_amount_cents || 0}c`;
-    });
-  const diag = `owner=${ownerCampaign || 'unknown'} config=${c.campaignId || 'none'} seen=[${seen.join(', ') || 'none'}] picked=${membership ? membership.status + '/' + membership.cents + 'c' : 'null'}`;
-  if (!membership || membership.status !== 'active_patron') console.warn(`[paid] verifyPatron: ${diag}`);
-  return { status: membership ? membership.status : null, cents: membership ? membership.cents : 0, diag };
-}
-
-function safeDecrypt(blob) {
-  try { return decrypt(blob); } catch { return null; }
-}
-
-export default {
-  getOwnerConfig, ownerStatus, saveOwnerConfig, disconnect,
-  defaultMinCents, patreonUrl, needsRefresh, refreshCreatorToken, creatorAccessToken,
-  pickCampaignMembership, fetchOwnerCampaignId, verifyPatron,
-};
Index: src/services/PasskeyService.js
===================================================================
--- src/services/PasskeyService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,137 +1,0 @@
-// Paid posts (klonkt-demo-aki) slice 3: passkey registration + verification for
-// pseudonymous entitlements. Uses @simplewebauthn/server. Cookie-less: the
-// challenge is not kept in a session but travels inside a signed blob
-// (CryptoBox.signBlob) that the client returns, so there is nothing to store
-// between the two requests. An entitlement is {passkey, site, cents, expiry}
-// with NO patron identity.
-import crypto from 'crypto';
-import db from '../config/database.js';
-
-// Lazy so a not-yet-installed dependency can never crash app boot; only the
-// paid passkey flow fails until `npm ci` has run.
-let _lib = null;
-async function lib() { if (!_lib) _lib = await import('@simplewebauthn/server'); return _lib; }
-
-const DEFAULT_TTL_DAYS = 32;   // aligns with Patreon's monthly cycle; re-link after
-
-// rpID is the site host; origin is the full base URL.
-export function rpFor(base) {
-  let host = ''; try { host = new URL(base).host.split(':')[0]; } catch { /* keep empty */ }
-  return { rpID: host, origin: String(base).replace(/\/+$/, '') };
-}
-
-// Registration options for a fresh, discoverable (usernameless) passkey. The
-// user handle is random: the credential is pseudonymous by design.
-export async function registrationOptions(base, siteSlug) {
-  const { rpID } = rpFor(base);
-  const { generateRegistrationOptions } = await lib();
-  return generateRegistrationOptions({
-    rpName: `Supporter of ${siteSlug}`,
-    rpID,
-    userName: 'supporter',
-    userDisplayName: 'Supporter',
-    userID: crypto.randomBytes(16),
-    attestationType: 'none',
-    authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' },
-    timeout: 120000,
-  });
-}
-
-// Verify a registration response against the challenge (read from the signed
-// blob by the caller). Returns the credential to store, or null.
-export async function verifyRegistration(base, response, expectedChallenge) {
-  const { rpID, origin } = rpFor(base);
-  let v;
-  try {
-    const { verifyRegistrationResponse } = await lib();
-    v = await verifyRegistrationResponse({
-      response,
-      expectedChallenge,
-      expectedOrigin: origin,
-      expectedRPID: rpID,
-      requireUserVerification: false,
-    });
-  } catch { return null; }
-  if (!v || !v.verified || !v.registrationInfo) return null;
-  const cred = v.registrationInfo.credential;
-  return {
-    credentialId: cred.id,                                        // base64url string
-    publicKey: Buffer.from(cred.publicKey).toString('base64url'), // COSE key bytes
-    counter: cred.counter || 0,
-    transports: response.response && response.response.transports ? JSON.stringify(response.response.transports) : null,
-  };
-}
-
-// Authentication (assertion) options for the unlock. Discoverable credentials,
-// so allowCredentials is empty and the browser offers the site's passkeys.
-export async function authenticationOptions(base) {
-  const { rpID } = rpFor(base);
-  const { generateAuthenticationOptions } = await lib();
-  return generateAuthenticationOptions({ rpID, userVerification: 'preferred', allowCredentials: [] });
-}
-
-// Verify an assertion against a stored entitlement row. Returns { newCounter }
-// or null. Challenge is read from the signed blob by the caller.
-export async function verifyAssertion(base, response, expectedChallenge, ent) {
-  const { rpID, origin } = rpFor(base);
-  let v;
-  try {
-    const { verifyAuthenticationResponse } = await lib();
-    v = await verifyAuthenticationResponse({
-      response,
-      expectedChallenge,
-      expectedOrigin: origin,
-      expectedRPID: rpID,
-      requireUserVerification: false,
-      credential: {
-        id: ent.credential_id,
-        publicKey: Buffer.from(ent.public_key, 'base64url'),
-        counter: ent.counter || 0,
-        transports: ent.transports ? JSON.parse(ent.transports) : undefined,
-      },
-    });
-  } catch { return null; }
-  if (!v || !v.verified) return null;
-  return { newCounter: v.authenticationInfo.newCounter };
-}
-
-// Bump the signature counter after a successful assertion (clone detection).
-export function bumpCounter(credentialId, newCounter) {
-  db.prepare('UPDATE paid_entitlements SET counter = ? WHERE credential_id = ?').run(newCounter || 0, credentialId);
-}
-
-// Store (or refresh) a pseudonymous entitlement for this passkey.
-export function storeEntitlement({ credentialId, siteId, publicKey, counter, transports, minCents, ttlDays = DEFAULT_TTL_DAYS }) {
-  const expiresAt = Math.floor(Date.now() / 1000) + ttlDays * 86400;
-  db.prepare(`INSERT INTO paid_entitlements
-      (credential_id, site_id, public_key, counter, transports, min_cents, expires_at, created_at)
-      VALUES (?,?,?,?,?,?,?,CURRENT_TIMESTAMP)
-    ON CONFLICT(credential_id) DO UPDATE SET
-      public_key=excluded.public_key, counter=excluded.counter, transports=excluded.transports,
-      min_cents=excluded.min_cents, expires_at=excluded.expires_at`)
-    .run(credentialId, siteId, publicKey, counter || 0, transports || null, Math.max(0, minCents || 0), expiresAt);
-  return expiresAt;
-}
-
-// A valid, unexpired entitlement for this passkey on this site, else null.
-export function getEntitlement(credentialId, siteId) {
-  const row = db.prepare('SELECT * FROM paid_entitlements WHERE credential_id = ? AND site_id = ?').get(credentialId, siteId);
-  if (!row) return null;
-  if ((row.expires_at || 0) < Math.floor(Date.now() / 1000)) return null;
-  return row;
-}
-
-export function deleteEntitlement(credentialId) {
-  return db.prepare('DELETE FROM paid_entitlements WHERE credential_id = ?').run(credentialId).changes > 0;
-}
-
-// Prune expired entitlements (Scheduler, slice 5).
-export function pruneExpired() {
-  return db.prepare('DELETE FROM paid_entitlements WHERE expires_at < ?').run(Math.floor(Date.now() / 1000)).changes;
-}
-
-export default {
-  rpFor, registrationOptions, verifyRegistration, storeEntitlement,
-  getEntitlement, deleteEntitlement, pruneExpired,
-  authenticationOptions, verifyAssertion, bumpCounter,
-};
Index: src/services/PatreonService.js
===================================================================
--- src/services/PatreonService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,103 +1,0 @@
-// Patreon entitlement (premium layer).
-//
-// Model (Klonkt, 2026-06): the app + all updates are free. A set of premium
-// extras (newsletter, download-for-email, release planning + fan-only posts,
-// EPK/press kit, pro statistics, link-in-bio, embeddable player, show agenda)
-// is gated behind a $16 lifetime Patreon supporter status. The central license
-// server (license.klonkt.com)
-// checks Patreon and signs an Ed25519 JWT "entitlement token". THIS instance
-// verifies that token OFFLINE using the server's public key — a cracked/forked
-// self-host cannot forge a valid token (only the license server can sign).
-// That is the real lock; feature flags themselves can be patched on self-host
-// (deliberately accepted: $16 < effort to crack).
-//
-// Premium gating is ON by default: the extras (newsletter, statistics, …)
-// require a linked Patreon supporter. KLONKT_PREMIUM_ENABLED=off disables the
-// premium layer (intended for internal/demo instances).
-
-import crypto from 'node:crypto';
-import { getSetting, setSetting } from './SettingsService.js';
-
-const LICENSE_URL = (process.env.KLONKT_LICENSE_URL || 'https://license.klonkt.com').replace(/\/$/, '');
-const ISSUER = 'klonkt-license';
-
-export function premiumEnabled() {
-  // Default ON; only an explicit 'off' disables the premium layer.
-  return String(process.env.KLONKT_PREMIUM_ENABLED || 'on').toLowerCase() !== 'off';
-}
-export function licenseBase() { return LICENSE_URL; }
-
-// --- Cache the license-server public key (for offline verification) ---
-let _pubKey = null;
-async function licensePublicKey() {
-  if (_pubKey) return _pubKey;
-  const res = await fetch(`${LICENSE_URL}/pubkey`);
-  if (!res.ok) throw new Error('pubkey fetch failed: ' + res.status);
-  const pem = await res.text();
-  _pubKey = crypto.createPublicKey(pem); // SPKI-PEM -> Ed25519 public key
-  return _pubKey;
-}
-
-function b64urlToBuf(s) {
-  return Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64');
-}
-
-// Verify an entitlement token (EdDSA JWT from the license server). Throws on
-// invalid signature, issuer, or expiry. Returns the claims on success.
-export async function verifyEntitlementToken(token) {
-  const parts = String(token || '').split('.');
-  if (parts.length !== 3) throw new Error('malformed token');
-  const [h, p, s] = parts;
-  const header = JSON.parse(b64urlToBuf(h).toString('utf8'));
-  if (header.alg !== 'EdDSA') throw new Error('unexpected alg');
-  const key = await licensePublicKey();
-  const ok = crypto.verify(null, Buffer.from(`${h}.${p}`), key, b64urlToBuf(s));
-  if (!ok) throw new Error('invalid signature');
-  const payload = JSON.parse(b64urlToBuf(p).toString('utf8'));
-  if (payload.iss !== ISSUER) throw new Error('unexpected issuer');
-  if (payload.exp && payload.exp * 1000 < Date.now()) throw new Error('expired token');
-  return payload; // { sub, entitled, plan, lifetime_support_cents, exp, ... }
-}
-
-export function storeEntitlement(payload, token) {
-  setSetting('patreon_entitled', payload.entitled ? '1' : '0');
-  setSetting('patreon_sub', String(payload.sub || ''));
-  setSetting('patreon_support_cents', String(payload.lifetime_support_cents || 0));
-  setSetting('patreon_token_exp', String(payload.exp || 0));
-  setSetting('patreon_token', token || '');
-}
-
-export function clearEntitlement() {
-  for (const k of ['patreon_entitled', 'patreon_sub', 'patreon_support_cents', 'patreon_token_exp', 'patreon_token']) {
-    setSetting(k, '');
-  }
-}
-
-// Is this instance premium? Premium layer enabled + a valid, non-expired,
-// entitled stored token. Patreon lifetime never decreases, so re-linking
-// after expiry always succeeds.
-export function isPremium() {
-  if (!premiumEnabled()) return false;
-  if (getSetting('patreon_entitled') !== '1') return false;
-  const exp = Number(getSetting('patreon_token_exp', '0')) || 0;
-  if (exp && exp * 1000 < Date.now()) return false;
-  return true;
-}
-
-// Is a premium feature available? True if the premium layer is OFF (nothing is
-// gated — current behavior), or ON and this instance is entitled. False only
-// if premium is on but there is no valid Patreon connection (= paywall).
-export function premiumUnlocked() {
-  return !premiumEnabled() || isPremium();
-}
-
-export function entitlementStatus() {
-  return {
-    enabled: premiumEnabled(),
-    premium: isPremium(),
-    connected: getSetting('patreon_entitled') === '1',
-    sub: getSetting('patreon_sub', '') || null,
-    supportCents: Number(getSetting('patreon_support_cents', '0')) || 0,
-    exp: Number(getSetting('patreon_token_exp', '0')) || 0,
-  };
-}
Index: src/services/PermissionsService.js
===================================================================
--- src/services/PermissionsService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/services/PermissionsService.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -3,6 +3,4 @@
  * Used in templates to show/hide edit buttons, delete buttons, etc.
  */
-
-import db from '../config/database.js';
 
 class PermissionsService {
@@ -46,5 +44,4 @@
     if (!user) return false;
     if (user.role === 'god') return true;
-    if (!site) return false; // no site context (e.g. hub landing) -> nothing to post to
     if (user.id === site.owner_id) return true; // Site owner
     if (this.canAdminSite(user, site)) return true;
@@ -56,13 +53,9 @@
    */
   static canAdminSite(user, site) {
-    if (!user || !site) return false;
+    if (!user) return false;
     if (user.role === 'god') return true;
     if (user.id === site.owner_id) return true;
-    // Assigned co-admin (collaborator) via site_members. Previously read from
-    // a never-populated user.siteRoles → dead code; now queried directly on
-    // the table (a few checks per page, indexed = cheap).
-    return !!db.prepare(
-      "SELECT 1 FROM site_members WHERE site_id = ? AND user_id = ? AND role = 'admin' LIMIT 1"
-    ).get(site.id, user.id);
+    // Check site_members table
+    return user.siteRoles && user.siteRoles[site.id] === 'admin';
   }
 
Index: src/services/PlatformIcons.js
===================================================================
--- src/services/PlatformIcons.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/services/PlatformIcons.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -35,7 +35,5 @@
     brand: '#FC3C44',
     host: 'music.apple.com',
-    // The classic Apple mark (bitten apple + leaf). The previous path was a garbled
-    // app-tile outline that didn't read as the Apple logo at icon size.
-    svg: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.03 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.56-1.702"/></svg>',
+    svg: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23.997 6.124c0-.738-.065-1.47-.24-2.19-.317-1.31-1.062-2.31-2.18-3.043C21.003.517 20.373.285 19.7.164c-.517-.093-1.038-.135-1.564-.15-.04-.003-.083-.01-.124-.013H5.988c-.152.01-.303.017-.455.026C4.786.07 4.043.15 3.34.428 2.004.958 1.04 1.88.475 3.208c-.192.448-.292.925-.363 1.408-.056.392-.088.785-.1 1.18 0 .032-.007.062-.01.093v12.223c.01.14.017.283.027.424.05.815.154 1.624.497 2.373.65 1.42 1.738 2.353 3.234 2.802.42.127.856.187 1.293.228.555.053 1.11.06 1.667.06h11.03c.525 0 1.048-.034 1.57-.1.823-.106 1.597-.35 2.296-.81 1.36-.89 2.193-2.13 2.515-3.728.057-.32.084-.65.104-.978.046-.78.06-1.54.06-2.323-.013-.05-.013-.1-.013-.146 0-.13 0-.26.013-.39 0-.026 0-.04-.013-.067V7.197c0-.35-.023-.7-.05-1.05M17.875 14.7c-.166.48-.37.94-.624 1.38-.504.866-1.197 1.57-2.078 2.057-.36.198-.738.358-1.13.477-.96.292-1.962.353-2.96.158-.978-.193-1.876-.602-2.69-1.184-.83-.595-1.526-1.327-2.09-2.176-.512-.77-.91-1.604-1.196-2.49-.286-.886-.46-1.81-.504-2.74-.044-.94.043-1.87.27-2.78.226-.91.59-1.78 1.085-2.6.494-.82 1.114-1.55 1.85-2.18.736-.628 1.586-1.13 2.522-1.5.936-.37 1.94-.555 2.967-.55.7.003 1.4.094 2.08.27.68.176 1.34.44 1.952.78.04.022.08.043.122.064-.005.046-.012.09-.018.135-.063.514-.158 1.02-.29 1.516-.06.226-.13.45-.21.668-.11.28-.236.555-.376.82-.1.19-.21.376-.327.557-.16.247-.336.482-.524.706-.187.224-.39.435-.598.638-.115.111-.234.218-.355.323-.04.034-.08.066-.118.1-.205.176-.42.337-.642.488-.27.183-.554.346-.852.488l-.044.022c-.003-.005-.005-.01-.008-.013-.002-.005-.005-.01-.008-.014v-.013z"/></svg>',
   },
   youtube: {
@@ -87,39 +85,10 @@
     svg: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6zm-2 0l-8 5-8-5h16zm0 12H4V8l8 5 8-5v10z"/></svg>',
   },
-  telegram: {
-    label: 'Telegram',
-    brand: '#229ED9',
-    host: 't.me',
-    svg: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/></svg>',
-  },
-  whatsapp: {
-    label: 'WhatsApp',
-    brand: '#25D366',
-    host: 'wa.me',
-    svg: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 0 1-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 0 1-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 0 1 2.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0 0 12.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 0 0 5.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 0 0-3.48-8.413z"/></svg>',
-  },
-  phone: {
-    label: 'Telefoon',
-    brand: '#34A853',
-    host: '',
-    svg: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z"/></svg>',
-  },
 };
 
 export const PLATFORM_ORDER = [
   'spotify','bandcamp','soundcloud','applemusic','youtube','vimeo',
-  'instagram','twitter','mastodon','github','website','email','telegram','whatsapp','phone',
+  'instagram','twitter','mastodon','github','website','email',
 ];
-
-// Contact-type platforms (shown grouped in the profile summary).
-export const CONTACT_PLATFORMS = ['email','phone','telegram','whatsapp'];
-
-// Build the real href for a profile link (tel:/mailto: prefixes where needed).
-export function linkHref(platform, url) {
-  const u = String(url || '').trim();
-  if (platform === 'email' && u && u.indexOf('mailto:') !== 0) return 'mailto:' + u;
-  if (platform === 'phone' && u && !/^tel:/i.test(u)) return 'tel:' + u.replace(/[^\d+]/g, '');
-  return u;
-}
 
 export function listPlatforms() {
@@ -127,3 +96,3 @@
 }
 
-export default { PLATFORMS, PLATFORM_ORDER, listPlatforms, CONTACT_PLATFORMS, linkHref };
+export default { PLATFORMS, PLATFORM_ORDER, listPlatforms };
Index: src/services/PlaylistService.js
===================================================================
--- src/services/PlaylistService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/services/PlaylistService.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -14,57 +14,4 @@
 import db from '../config/database.js';
 import { v4 as uuid } from 'uuid';
-import { SOORTEN } from '../assets/js/shared/post-music-type.js';
-
-/**
- * Een volledige datum of niets (shaer-756s).
- *
- * STRIKT, en dat is de hele functie. `year` bestaat al en blijft; dit veld
- * bestaat juist omdat een jaartal geen uitgavedatum is. Zou hij "2024"
- * doorlaten en er 2024-01-01 van maken, dan stond er straks een dag op de
- * federatie die niemand ooit heeft ingevoerd -- en dan hadden we het veld net
- * zo goed niet kunnen toevoegen.
- *
- * Ook 2024-02-31 valt af: dat is geen strengheid om de strengheid, Date rolt
- * hem stilletjes door naar 2 maart en dan slaan we iets anders op dan er
- * ingetypt is.
- */
-function normDatum(v) {
-  const s = String(v == null ? '' : v).trim();
-  if (!s) return null;
-  if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
-  const d = new Date(`${s}T00:00:00Z`);
-  return Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== s ? null : s;
-}
-
-/** Een MusicBrainz-id of niets. Zelfde vorm als sites.mb_artist_id. */
-function normMbid(v) {
-  const s = String(v == null ? '' : v).trim().toLowerCase();
-  return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(s) ? s : null;
-}
-
-/**
- * De drie soorten die een playlist kan zijn, op EEN plek.
- *
- * Stond eerder vijf keer als `data.kind === 'playlist' ? 'playlist' : 'album'`
- * verspreid over dit bestand. Met twee soorten kon dat nog; bij een derde is
- * het een fout die staat te wachten, want een van de vijf vergeten betekent dat
- * een mixtape stilletjes als album wordt opgeslagen en als Album de deur uit
- * gaat.
- *
- * Album blijft de terugval: een onbekende waarde hoort niet stilzwijgend iets
- * nieuws te worden. De lijst zelf staat in de gedeelde pure module, want de
- * editor in de browser moet dezelfde drie kennen.
- */
-export function normKind(v) {
-  const s = String(v == null ? '' : v).trim().toLowerCase();
-  return SOORTEN.includes(s) ? s : 'album';
-}
-
-/**
- * Draagt deze soort uitgavegegevens? Alleen een album. Een afspeellijst en een
- * mixtape hebben geen uitgavedatum en geen release-id: het zijn samenstellingen
- * van andermans of eigen werk, geen uitgave op zichzelf.
- */
-export const isUitgave = (kind) => normKind(kind) === 'album';
 
 class PlaylistService {
@@ -105,5 +52,4 @@
     const rows = db.prepare(`
       SELECT p.id, p.title, p.artist, p.year, p.cover_url, p.kind,
-             p.release_date, p.mb_release_id,
              p.created_at, p.updated_at,
              (SELECT COUNT(*) FROM playlist_tracks WHERE playlist_id = p.id) AS track_count
@@ -118,7 +64,5 @@
       year: r.year || 0,
       cover: r.cover_url || '',
-      kind: normKind(r.kind),
-      release_date: r.release_date || '',
-      mb_release_id: r.mb_release_id || '',
+      kind: r.kind || 'album',
       track_count: r.track_count,
       created_at: r.created_at,
@@ -133,14 +77,14 @@
    * Returns null if the playlist doesn't exist.
    *
-   * `urlFor` is an optional callback that takes a media filename and returns
-   * its stream URL. If not provided, tracks come back with no `url` and the
-   * caller has to resolve them. The render pipeline in posts.js always passes
-   * urlFor.
-   */
-  static get(siteId, id, urlFor) {
+   * `signUrl` is an optional callback that takes a media filename and returns
+   * a (possibly signed) URL. If not provided, tracks come back with no `url`
+   * and the caller has to resolve them. The render pipeline in posts.js
+   * always passes signUrl.
+   */
+  static get(siteId, id, signUrl) {
     id = this.normalizeId(id);
     if (!id) return null;
     const p = db.prepare(`
-      SELECT id, title, artist, year, cover_url, kind, release_date, mb_release_id, created_at, updated_at
+      SELECT id, title, artist, year, cover_url, kind, created_at, updated_at
       FROM playlists WHERE site_id = ? AND id = ?
     `).get(siteId, id);
@@ -150,6 +94,5 @@
     // filenames (only tracks with a media file are playable).
     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
+      SELECT t.id, t.title, t.artist, t.duration, t.cover_url, m.filename
       FROM playlist_tracks pt
       JOIN audio_tracks t   ON t.id = pt.track_id
@@ -159,19 +102,4 @@
     `).all(id);
 
-    const mappedTracks = tracks
-      // Link-only tracks (no media file) remain in the list with url ''.
-      .map(t => ({
-        id: t.id,
-        title: t.title || 'Untitled',
-        artist: t.artist || p.artist || '',
-        cover: t.cover_url || p.cover_url || '',
-        duration: t.duration || 0,
-        link_spotify: t.link_spotify || '',
-        link_youtube: t.link_youtube || '',
-        link_soundcloud: t.link_soundcloud || '',
-        url: (t.filename && urlFor) ? urlFor(t.filename) : '',
-      }));
-    // No playlist cover? Fall back to the first track cover so the card isn't empty.
-    const fallbackCover = (mappedTracks.find(t => t.cover) || {}).cover || '';
     return {
       id: p.id,
@@ -179,16 +107,16 @@
       artist: p.artist || '',
       year: p.year || 0,
-      cover: p.cover_url || fallbackCover,
-      kind: normKind(p.kind),
-      // created_at hoort erbij omdat de AP-kant er `published` van maakt. Zonder
-      // dit veld viel buildAlbumObject terug op 1970, en dat stond op 16-8
-      // gewoon op de federatie.
-      created_at: p.created_at,
-      // Leeg als het een afspeellijst is -- de opslag houdt ze daar al leeg,
-      // maar dit is de plek waar de editor leest en die mag niet afhangen van
-      // wat er toevallig in de kolom stond.
-      release_date: isUitgave(p.kind) ? (p.release_date || '') : '',
-      mb_release_id: isUitgave(p.kind) ? (p.mb_release_id || '') : '',
-      tracks: mappedTracks,
+      cover: p.cover_url || '',
+      kind: (p.kind === 'playlist') ? 'playlist' : 'album',
+      tracks: tracks
+        .filter(t => t.filename)  // skip orphaned references
+        .map(t => ({
+          id: t.id,
+          title: t.title || 'Untitled',
+          artist: t.artist || p.artist || '',
+          cover: t.cover_url || p.cover_url || '',
+          duration: t.duration || 0,
+          url: signUrl ? signUrl(t.filename).url : null,
+        })),
     };
   }
@@ -204,20 +132,10 @@
     const id = this.generateId(siteId, title);
     const now = new Date().toISOString();
-    const kind = normKind(data.kind);
-
-    // Alleen een UITGAVE draagt deze twee. Een afspeellijst heeft geen
-    // uitgavedatum en geen release-id, en dat onderscheid is precies wat de
-    // keuze album/playlist betekent (shaer-cyg). Het afdwingen gebeurt HIER en
-    // niet alleen in het scherm: een scherm kun je omzeilen -- de API ligt open
-    // voor de post-editor -- en dan staat er stille rommel op een mixtape die
-    // later als Album de deur uit gaat.
-    const uitgave = isUitgave(kind);
-    const releaseDate = uitgave ? normDatum(data.release_date) : null;
-    const mbRelease = uitgave ? normMbid(data.mb_release_id) : null;
+    const kind = data.kind === 'playlist' ? 'playlist' : 'album';
 
     const tx = db.transaction(() => {
       db.prepare(`
-        INSERT INTO playlists (id, site_id, title, artist, year, cover_url, kind, release_date, mb_release_id, created_at, updated_at)
-        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+        INSERT INTO playlists (id, site_id, title, artist, year, cover_url, kind, created_at, updated_at)
+        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
       `).run(
         id, siteId, title,
@@ -225,5 +143,5 @@
         Number.isFinite(+data.year) && +data.year > 0 ? +data.year : null,
         String(data.cover || '').trim() || null,
-        kind, releaseDate, mbRelease, now, now,
+        kind, now, now,
       );
       this._writeTracks(id, siteId, data.tracks);
@@ -246,5 +164,5 @@
     if (!id) return false;
     const existing = db.prepare(
-      'SELECT id, kind FROM playlists WHERE site_id = ? AND id = ?'
+      'SELECT id FROM playlists WHERE site_id = ? AND id = ?'
     ).get(siteId, id);
     if (!existing) return false;
@@ -252,9 +170,4 @@
     const fields = [];
     const values = [];
-    // Wat wordt het NA deze wijziging? `kind` hoeft niet in data te staan, dus
-    // val terug op wat er ligt.
-    const nieuwKind = Object.prototype.hasOwnProperty.call(data, 'kind')
-      ? normKind(data.kind)
-      : normKind(existing.kind);
     if (Object.prototype.hasOwnProperty.call(data, 'title')) {
       const v = String(data.title || '').trim();
@@ -273,21 +186,5 @@
     }
     if (Object.prototype.hasOwnProperty.call(data, 'kind')) {
-      fields.push('kind = ?'); values.push(nieuwKind);
-    }
-    // De uitgavevelden. Wordt dit een afspeellijst, dan gaan ze ALTIJD leeg --
-    // ook als de aanroeper er niets over zei. Anders houdt een album dat je tot
-    // mixtape ombouwt zijn uitgavedatum en zijn release-id, en die duiken dan
-    // weer op zodra iemand hem terugzet. Een veld dat niet meer mag bestaan
-    // hoort weg te zijn, niet te wachten.
-    if (nieuwKind !== 'album') {
-      fields.push('release_date = ?'); values.push(null);
-      fields.push('mb_release_id = ?'); values.push(null);
-    } else {
-      if (Object.prototype.hasOwnProperty.call(data, 'release_date')) {
-        fields.push('release_date = ?'); values.push(normDatum(data.release_date));
-      }
-      if (Object.prototype.hasOwnProperty.call(data, 'mb_release_id')) {
-        fields.push('mb_release_id = ?'); values.push(normMbid(data.mb_release_id));
-      }
+      fields.push('kind = ?'); values.push(data.kind === 'playlist' ? 'playlist' : 'album');
     }
     fields.push('updated_at = ?'); values.push(new Date().toISOString());
@@ -316,5 +213,5 @@
    * Delete a playlist. Track references in playlist_tracks are removed
    * automatically via ON DELETE CASCADE. Posts that embed this playlist
-   * will render a "playlist not found" placeholder.
+   * will render a "playlist niet gevonden" placeholder.
    */
   static delete(siteId, id) {
Index: src/services/PostAccessService.js
===================================================================
--- src/services/PostAccessService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,108 +1,0 @@
-/**
- * Wat mag DEZE bezoeker van DIT bericht zien?
- *
- * De vraag stond tot nu toe alleen in de postroute, en daar werd hij beantwoord
- * door een ANDERE PAGINA te renderen: paid-gate of fan-gate in plaats van het
- * bericht. Dat werkt zolang een bericht een pagina is. Zodra de tijdlijn hele
- * berichten toont, moet hetzelfde besluit een STUK opleveren dat tussen de
- * andere berichten past -- en twee plekken die allebei zelf beslissen wie wat
- * mag zien, zijn een lek dat wacht op een gelegenheid.
- *
- * Dus één functie, en de route en de tijdlijn lezen er allebei uit. Deze module
- * bepaalt alleen WAT er mag; hoe het eruitziet is aan de sjablonen.
- */
-import PermissionsService from './PermissionsService.js';
-
-/**
- * De teaser van een betaald bericht: nooit meer dan de eerste alinea.
- *
- * Stond als paidTeaser() in routes/posts.js. Hier neergezet omdat de tijdlijn
- * hem ook nodig heeft, en een tweede kopie vroeg of laat meer prijsgeeft dan
- * deze -- precies de fout die je bij een betaalmuur niet wilt maken.
- */
-export function paidTeaser(post, max = 280) {
-  if (post && post.excerpt && String(post.excerpt).trim()) return String(post.excerpt).trim();
-  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;
-}
-
-/**
- * Het besluit, als één waarde.
- *
- *   'full'      → de hele inhoud
- *   'paid'      → betaalmuur: teaser + ontgrendelknop
- *   'fan'       → alleen voor fans: teaser + inloggen
- *   'forbidden' → niet gepubliceerd en jij mag het niet bewerken
- *
- * De VOLGORDE is niet vrijblijvend. Betaald wordt vóór fan-only getoetst, want
- * een bericht dat allebei is gaat open met een passkey en niet met een
- * Klonkt-login; andersom belandt een anonieme supporter op het inlogscherm en
- * ziet hij de ontgrendelknop nooit. Die regel stond als commentaar in de route
- * en verhuist hier mee, want hij hoort bij het besluit en niet bij de pagina.
- *
- * @param post     de rij uit `posts`
- * @param viewer   { user, site, unlockedSlug, isFollower } -- unlockedSlug is de
- *                 slug uit een vers ?u=-bewijs van /paid/unlock, al geverifieerd
- *                 door de aanroeper; isFollower komt uit OpenWebAuthService en
- *                 zegt dat een bewezen fedi-actor deze site volgt. Deze module
- *                 doet geen crypto en raakt de database niet aan: ophalen doet
- *                 de aanroeper, beslissen doet deze.
- */
-export function postAccess(post, { user = null, site = null, unlockedSlug = null, isFollower = false } = {}) {
-  if (!post) return 'forbidden';
-
-  const canEdit = !!(user && PermissionsService.canEditPost(user, post, site));
-
-  // Een concept is van de maker. Dit stond vóór beide poorten in de route en
-  // hoort dat te blijven: over een ongepubliceerd bericht valt niets te kopen
-  // en niets te ontgrendelen.
-  if (post.status !== 'published' && !canEdit) return 'forbidden';
-
-  // De eigenaar/redacteur ziet altijd zijn eigen werk, betaald of niet.
-  if (canEdit) return 'full';
-
-  if (post.paid && String(unlockedSlug || '') !== String(post.slug)) return 'paid';
-  // `fan_only` betekende altijd al "mijn volgers op de fediverse", maar de poort
-  // vroeg om een lokaal account -- de verkeerde vraag, die juist de mensen
-  // buitensloot voor wie hij openstond. `isFollower` is het antwoord op de
-  // ECHTE vraag: een bezoeker die via OpenWebAuth bewees @iemand@ergens te zijn
-  // en die deze site volgt. Wie dat bewijs levert, komt binnen.
-  //
-  // Het lokale account blijft er ook door, zoals het altijd deed: dat is een
-  // andere manier om te weten wie iemand is, niet een mindere.
-  if (post.fan_only && !user && !isFollower) return 'fan';
-  return 'full';
-}
-
-/** Handig voor sjablonen: mag de bezoeker de echte inhoud zien? */
-export function canReadBody(post, viewer) { return postAccess(post, viewer) === 'full'; }
-
-/**
- * Eén bericht, klaar voor de tijdlijn.
- *
- * DE HELE REDEN DAT DIT EEN FUNCTIE IS: het lijf wordt alleen GERENDERD als het
- * ook getoond mag worden. Een sjabloon dat zelf `<% if (access === 'full') %>`
- * doet krijgt de volledige tekst tóch mee in het model, en dan is het één
- * vergeten conditie -- of één `?partial=1` die net iets anders samenstelt --
- * tussen een betaalmuur en de tekst erachter. Hier komt hij niet eens mee.
- *
- * `renderBody` wordt ingespoten (posts.js levert renderPostBodyHtml), zodat
- * deze module niets van routes of sjablonen hoeft te weten.
- */
-export function postEntry(post, viewer, { renderBody } = {}) {
-  const access = postAccess(post, viewer);
-  const full = access === 'full';
-  return {
-    slug: post && post.slug,
-    title: (post && post.title) || '',
-    access,
-    // Alleen bij een gesloten poort een teaser, en nooit allebei: de teaser is
-    // de vervanging van het lijf, niet een voorproefje ernaast.
-    teaser: full || access === 'forbidden' ? null : paidTeaser(post),
-    content_html: full && typeof renderBody === 'function' ? renderBody(post) : null,
-  };
-}
-
-export default { postAccess, canReadBody, paidTeaser, postEntry };
Index: src/services/PrutterService.js
===================================================================
--- src/services/PrutterService.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
+++ src/services/PrutterService.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -0,0 +1,196 @@
+/**
+ * PrutterService — Real-time Direct Messaging
+ * 
+ * Features:
+ * - Per-conversation (user A ↔ user B)
+ * - Optional site-specific (conversations tied to a community)
+ * - WebSocket real-time notifications
+ * - Message history in SQLite
+ * - Unread message tracking
+ */
+
+import { v4 as uuid } from 'uuid';
+
+class PrutterService {
+  constructor(db) {
+    this.db = db;
+    this.wsConnections = new Map(); // userId → Set<WebSocket>
+  }
+
+  /**
+   * Get or create conversation
+   */
+  getOrCreateConversation(userA, userB, siteId = null) {
+    if (!userA || !userB) throw new Error('Both users required');
+    
+    // Normalize order: always smaller ID first
+    const [u1, u2] = userA < userB ? [userA, userB] : [userB, userA];
+
+    const existing = this.db.prepare(`
+      SELECT * FROM conversations
+      WHERE (user_a_id = ? AND user_b_id = ? AND site_id IS ?)
+      LIMIT 1
+    `).get(u1, u2, siteId);
+
+    if (existing) {
+      return existing;
+    }
+
+    const convId = uuid();
+    this.db.prepare(`
+      INSERT INTO conversations (id, user_a_id, user_b_id, site_id)
+      VALUES (?, ?, ?, ?)
+    `).run(convId, u1, u2, siteId);
+
+    return { id: convId, user_a_id: u1, user_b_id: u2, site_id: siteId };
+  }
+
+  /**
+   * Send message
+   */
+  sendMessage(conversationId, authorId, content) {
+    if (!conversationId || !authorId || !content) {
+      throw new Error('Missing required fields');
+    }
+
+    const msgId = uuid();
+    const now = new Date().toISOString();
+
+    this.db.prepare(`
+      INSERT INTO messages (id, conversation_id, author_id, content, created_at)
+      VALUES (?, ?, ?, ?, ?)
+    `).run(msgId, conversationId, authorId, content, now);
+
+    // Update last_message_at on conversation
+    this.db.prepare(`
+      UPDATE conversations SET last_message_at = ? WHERE id = ?
+    `).run(now, conversationId);
+
+    // Fetch full message for response
+    const message = this.db.prepare(`
+      SELECT m.*, u.username, u.avatar_url
+      FROM messages m
+      JOIN users u ON m.author_id = u.id
+      WHERE m.id = ?
+    `).get(msgId);
+
+    // Notify recipient via WebSocket (if online)
+    const conv = this.db.prepare('SELECT * FROM conversations WHERE id = ?').get(conversationId);
+    const recipientId = conv.user_a_id === authorId ? conv.user_b_id : conv.user_a_id;
+    
+    this.notifyUser(recipientId, {
+      type: 'new_message',
+      conversationId,
+      message
+    });
+
+    return message;
+  }
+
+  /**
+   * Get conversation messages
+   */
+  getMessages(conversationId, limit = 50, offset = 0) {
+    return this.db.prepare(`
+      SELECT m.*, u.username, u.avatar_url
+      FROM messages m
+      JOIN users u ON m.author_id = u.id
+      WHERE m.conversation_id = ?
+      ORDER BY m.created_at DESC
+      LIMIT ? OFFSET ?
+    `).all(conversationId, limit, offset);
+  }
+
+  /**
+   * Get user's conversations (list)
+   */
+  getUserConversations(userId) {
+    return this.db.prepare(`
+      SELECT c.*,
+             CASE 
+               WHEN c.user_a_id = ? THEN u2.id
+               ELSE u1.id
+             END as other_user_id,
+             CASE 
+               WHEN c.user_a_id = ? THEN u2.username
+               ELSE u1.username
+             END as other_username,
+             CASE 
+               WHEN c.user_a_id = ? THEN u2.avatar_url
+               ELSE u1.avatar_url
+             END as other_avatar,
+             (SELECT COUNT(*) FROM messages m 
+              WHERE m.conversation_id = c.id 
+              AND m.author_id != ? 
+              AND m.read_at IS NULL) as unread_count,
+             (SELECT content FROM messages m 
+              WHERE m.conversation_id = c.id 
+              ORDER BY m.created_at DESC LIMIT 1) as last_message_preview
+      FROM conversations c
+      JOIN users u1 ON c.user_a_id = u1.id
+      JOIN users u2 ON c.user_b_id = u2.id
+      WHERE c.user_a_id = ? OR c.user_b_id = ?
+      ORDER BY c.last_message_at DESC
+    `).all(userId, userId, userId, userId, userId, userId);
+  }
+
+  /**
+   * Mark conversation messages as read
+   */
+  markAsRead(conversationId, userId) {
+    const now = new Date().toISOString();
+    this.db.prepare(`
+      UPDATE messages
+      SET read_at = ?
+      WHERE conversation_id = ? AND author_id != ? AND read_at IS NULL
+    `).run(now, conversationId, userId);
+  }
+
+  /**
+   * WebSocket connection management
+   */
+  addConnection(userId, ws) {
+    if (!this.wsConnections.has(userId)) {
+      this.wsConnections.set(userId, new Set());
+    }
+    this.wsConnections.get(userId).add(ws);
+  }
+
+  removeConnection(userId, ws) {
+    const conns = this.wsConnections.get(userId);
+    if (conns) {
+      conns.delete(ws);
+      if (conns.size === 0) {
+        this.wsConnections.delete(userId);
+      }
+    }
+  }
+
+  /**
+   * Notify user via WebSocket (if online)
+   */
+  notifyUser(userId, message) {
+    const conns = this.wsConnections.get(userId);
+    if (!conns) return;
+
+    const data = JSON.stringify(message);
+    for (const ws of conns) {
+      if (ws.readyState === 1) { // OPEN
+        ws.send(data);
+      }
+    }
+  }
+
+  /**
+   * Broadcast to all users in conversation (except sender)
+   */
+  broadcastToConversation(conversationId, senderUserId, message) {
+    const conv = this.db.prepare('SELECT * FROM conversations WHERE id = ?').get(conversationId);
+    if (!conv) return;
+
+    const otherUserId = conv.user_a_id === senderUserId ? conv.user_b_id : conv.user_a_id;
+    this.notifyUser(otherUserId, message);
+  }
+}
+
+export default PrutterService;
Index: src/services/PushService.js
===================================================================
--- src/services/PushService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,167 +1,0 @@
-// Web Push (VAPID) — background notifications to the owner's browser/PWA
-// (docs/webpush-design.md). RFC 8030 delivery + RFC 8291 payload encryption via
-// the `web-push` dependency (approved); the push service only ever sees
-// ciphertext. No cookies anywhere: only enabling/disabling is a logged-in action.
-import crypto from 'crypto';
-import fs from 'fs';
-import path from 'path';
-import { fileURLToPath } from 'url';
-import db from '../config/database.js';
-
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
-
-// Lazy so a not-yet-installed dependency can never crash app boot; only push
-// fails until `npm ci` has run (same pattern as @simplewebauthn/server).
-let _lib = null;
-async function lib() {
-  if (!_lib) { const m = await import('web-push'); _lib = m.default || m; }  // CJS: API on default
-  return _lib;
-}
-
-// ── VAPID keys ──────────────────────────────────────────────────────
-// env wins; otherwise a persisted key file next to the database, generated on
-// first use. NEVER regenerated while the file exists: new keys invalidate every
-// existing subscription. Back up storage/ as a whole (README).
-
-function keyFilePath() {
-  const dbPath = process.env.DATABASE_PATH || path.join(__dirname, '../../storage/database.sqlite');
-  const dir = dbPath === ':memory:' ? path.join(__dirname, '../../storage') : path.dirname(dbPath);
-  return path.join(dir, '.vapid');
-}
-
-// The VAPID subject: an https URL (PUBLIC_BASE_URL) or a mailto.
-function subject() {
-  if (process.env.VAPID_SUBJECT) return process.env.VAPID_SUBJECT;
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (/^https:\/\//.test(base)) return base;
-  const from = (process.env.SMTP_FROM || '').replace(/^.*</, '').replace(/>.*$/, '').trim();
-  return from.includes('@') ? `mailto:${from}` : 'mailto:webpush@invalid.local';
-}
-
-let _keys = null;
-async function vapidKeys() {
-  if (_keys) return _keys;
-  const envPub = process.env.VAPID_PUBLIC_KEY, envPriv = process.env.VAPID_PRIVATE_KEY;
-  if (envPub && envPriv) { _keys = { publicKey: envPub, privateKey: envPriv }; return _keys; }
-  const file = keyFilePath();
-  try {
-    const j = JSON.parse(fs.readFileSync(file, 'utf8'));
-    if (j && j.publicKey && j.privateKey) { _keys = j; return _keys; }
-  } catch { /* not created yet */ }
-  const { generateVAPIDKeys } = await lib();
-  const fresh = generateVAPIDKeys();
-  fs.mkdirSync(path.dirname(file), { recursive: true });
-  fs.writeFileSync(file, JSON.stringify(fresh), { mode: 0o600 });
-  try { fs.chmodSync(file, 0o600); } catch { /* non-POSIX fs */ }
-  _keys = fresh;
-  return _keys;
-}
-
-// The public key for the client (pushManager.subscribe). Null when the
-// dependency is missing or the key can't be persisted → feature stays gated.
-export async function publicKey() {
-  try { return (await vapidKeys()).publicKey; } catch { return null; }
-}
-
-export async function pushReady() { return (await publicKey()) !== null; }
-
-// ── Subscriptions ───────────────────────────────────────────────────
-
-// help (a ward's call for help) and guardian (adoption handshake) serve the
-// Guardian PWA and default ON: a guardian must never miss a call for help.
-export const DEFAULT_ALERTS = { follow: 1, reply: 1, like: 0, boost: 0, dm: 1, help: 1, guardian: 1 };
-
-export function saveSubscription({ endpoint, userId, p256dh, auth, alertTypes, uaLabel }) {
-  if (!endpoint || !userId || !p256dh || !auth) return false;
-  const alerts = JSON.stringify({ ...DEFAULT_ALERTS, ...(alertTypes || {}) });
-  db.prepare(`INSERT INTO push_subscriptions (endpoint, user_id, p256dh, auth, alert_types, ua_label, created_at)
-      VALUES (?,?,?,?,?,?,CURRENT_TIMESTAMP)
-    ON CONFLICT(endpoint) DO UPDATE SET
-      user_id=excluded.user_id, p256dh=excluded.p256dh, auth=excluded.auth,
-      alert_types=excluded.alert_types, ua_label=excluded.ua_label`)
-    .run(endpoint, userId, p256dh, auth, alerts, uaLabel || null);
-  return true;
-}
-
-export function deleteSubscription(endpoint) {
-  return db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint).changes > 0;
-}
-
-export function listSubscriptions(userId) {
-  return db.prepare('SELECT endpoint, alert_types, ua_label, created_at, last_ok_at FROM push_subscriptions WHERE user_id = ? ORDER BY created_at').all(userId);
-}
-
-export function updateAlerts(endpoint, userId, alertTypes) {
-  const alerts = JSON.stringify({ ...DEFAULT_ALERTS, ...(alertTypes || {}) });
-  return db.prepare('UPDATE push_subscriptions SET alert_types = ? WHERE endpoint = ? AND user_id = ?').run(alerts, endpoint, userId).changes > 0;
-}
-
-// ── Sending ─────────────────────────────────────────────────────────
-
-// Send one payload to one stored subscription row. 404/410 → the device is
-// gone or permission was revoked → delete the row (self-pruning).
-async function sendTo(row, payload) {
-  const wp = await lib();
-  const keys = await vapidKeys();
-  wp.setVapidDetails(subject(), keys.publicKey, keys.privateKey);
-  try {
-    await wp.sendNotification(
-      { endpoint: row.endpoint, keys: { p256dh: row.p256dh, auth: row.auth } },
-      JSON.stringify(payload),
-      { TTL: 3600 },
-    );
-    db.prepare('UPDATE push_subscriptions SET last_ok_at = CURRENT_TIMESTAMP WHERE endpoint = ?').run(row.endpoint);
-    return true;
-  } catch (e) {
-    if (e && (e.statusCode === 404 || e.statusCode === 410)) deleteSubscription(row.endpoint);
-    else console.warn('[push] send failed:', e && (e.statusCode || e.message));
-    return false;
-  }
-}
-
-// Burst throttle: a wave of likes or a mass-follow must not become a wave of
-// pushes. Per (user, type) at most one push per window; extras drop silently
-// (the events themselves are still in Berichten — only the ping is deduped).
-// In-memory is fine: one process, and a restart just means one extra ping.
-const THROTTLE_SECONDS = { follow: 60, reply: 30, dm: 30, like: 300, boost: 300, test: 0, help: 0, guardian: 30 };
-const _lastPush = new Map();
-export function throttled(userId, type, nowSeconds = Math.floor(Date.now() / 1000)) {
-  const windowS = THROTTLE_SECONDS[type] ?? 60;
-  if (!windowS) return false;
-  const key = `${userId}:${type}`;
-  const prev = _lastPush.get(key) || 0;
-  if (nowSeconds - prev < windowS) return true;
-  _lastPush.set(key, nowSeconds);
-  return false;
-}
-
-// Notify one user on all their devices, honouring per-type preferences.
-// type ∈ {follow, reply, like, boost, dm, help, guardian, test}. Fire-and-forget at call sites.
-export async function notifyUser(userId, { type, title, body, url }) {
-  if (!(await pushReady())) return 0;
-  if (throttled(userId, type)) return 0;
-  const rows = db.prepare('SELECT * FROM push_subscriptions WHERE user_id = ?').all(userId);
-  let sent = 0;
-  for (const row of rows) {
-    if (type !== 'test') {
-      let alerts = DEFAULT_ALERTS;
-      try { alerts = { ...DEFAULT_ALERTS, ...JSON.parse(row.alert_types || '{}') }; } catch { /* keep defaults */ }
-      if (!alerts[type]) continue;
-    }
-    if (await sendTo(row, { type, title: String(title || '').slice(0, 120), body: String(body || '').slice(0, 240), url: url || '/' })) sent++;
-  }
-  return sent;
-}
-
-// Notify the owner of a site (the usual entry point from the S2S inbox).
-export async function notifySite(slug, event) {
-  const row = db.prepare('SELECT owner_id FROM sites WHERE slug = ?').get(slug);
-  if (!row || !row.owner_id) return 0;
-  return notifyUser(row.owner_id, event);
-}
-
-export default {
-  publicKey, pushReady, DEFAULT_ALERTS, throttled,
-  saveSubscription, deleteSubscription, listSubscriptions, updateAlerts,
-  notifyUser, notifySite,
-};
Index: src/services/Scheduler.js
===================================================================
--- src/services/Scheduler.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,82 +1,0 @@
-/**
- * Scheduler — release planning (premium #3).
- *
- * Scheduled posts have status 'scheduled' + publish_at (future). A lightweight
- * timer flips them to 'published' once publish_at is reached. This means public
- * queries (status='published') need NO changes — a scheduled post simply isn't
- * 'published' yet and therefore invisible until that moment.
- */
-
-import db, { NU_ISO } from '../config/database.js';
-import HtmlSanitizerService from './HtmlSanitizerService.js';
-import ActivityPubService from './ActivityPubService.js';
-
-export function flipScheduledPosts() {
-  try {
-    const due = db.prepare(`
-      SELECT p.id, p.site_id, p.slug, p.title, p.content, p.cover_image_url, p.cover_video_url, p.cover_alt, p.language, p.fan_only, p.nsfw, p.content_warning, p.poll_json,
-             p.published_at, p.publish_at, p.created_at, u.username
-      FROM posts p JOIN users u ON u.id = p.author_id
-      WHERE p.status = 'scheduled' AND p.publish_at IS NOT NULL AND datetime(p.publish_at) <= datetime('now')
-    `).all();
-    if (!due.length) return 0;
-    const upd = db.prepare(
-      `UPDATE posts SET status = 'published', published_at = COALESCE(published_at, publish_at, ${NU_ISO}) WHERE id = ?`
-    );
-    const ftsDel = db.prepare('DELETE FROM posts_fts WHERE post_id = ?');
-    const fts = db.prepare('INSERT INTO posts_fts(content, title, author, post_id) VALUES (?, ?, ?, ?)');
-    const siteStmt = db.prepare('SELECT * FROM sites WHERE id = ?');
-    for (const p of due) {
-      upd.run(p.id);
-      // Delete-before-insert so a re-scheduled (previously published) post doesn't
-      // get a duplicate FTS row → duplicate search hits.
-      try { ftsDel.run(p.id); fts.run(HtmlSanitizerService.toPlainText(p.content || ''), p.title || '', p.username || '', p.id); } catch { /* FTS failure is non-fatal */ }
-      // ActivityPub: federate the now-published post to followers (fan_only → followers-only).
-      try {
-        const site = siteStmt.get(p.site_id);
-        if (site) {
-          ActivityPubService.deliverCreate(site, {
-            id: p.id, slug: p.slug, title: p.title || p.slug,
-            content: p.content, cover_image_url: p.cover_image_url || null, cover_video_url: p.cover_video_url || null, cover_alt: p.cover_alt, language: p.language,
-            published_at: p.published_at || p.publish_at, created_at: p.created_at, fan_only: p.fan_only, nsfw: p.nsfw, content_warning: p.content_warning, poll_json: p.poll_json,
-          }).catch(() => { /* best-effort */ });
-        }
-      } catch { /* non-fatal */ }
-    }
-    return due.length;
-  } catch { return 0; }
-}
-
-// Close hosted polls whose endTime has passed: mark them closed (once) and push the final
-// tally + closed state to followers as Update(Question). The `closed` flag in poll_json
-// guards against re-sending — a poll is only processed on the tick that crosses its endTime.
-export function closeExpiredPolls() {
-  try {
-    const due = db.prepare(`
-      SELECT id, poll_json FROM posts
-      WHERE poll_json IS NOT NULL
-        AND status = 'published'
-        AND json_extract(poll_json, '$.endTime') IS NOT NULL
-        AND IFNULL(json_extract(poll_json, '$.closed'), 0) = 0
-        AND datetime(json_extract(poll_json, '$.endTime')) <= datetime('now')
-    `).all();
-    if (!due.length) return 0;
-    const upd = db.prepare('UPDATE posts SET poll_json = ? WHERE id = ?');
-    for (const p of due) {
-      let d; try { d = JSON.parse(p.poll_json); } catch { continue; }
-      d.closed = true;
-      upd.run(JSON.stringify(d), p.id);
-      ActivityPubService.deliverPollUpdate(p.id).catch(() => { /* best-effort */ });
-    }
-    return due.length;
-  } catch { return 0; }
-}
-
-let _timer = null;
-function tick() { flipScheduledPosts(); closeExpiredPolls(); }
-export function startScheduler() {
-  tick();                               // run immediately on boot
-  if (_timer) return;
-  _timer = setInterval(tick, 60 * 1000); // every minute
-  if (_timer.unref) _timer.unref();
-}
Index: src/services/SettingsService.js
===================================================================
--- src/services/SettingsService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,42 +1,0 @@
-// Global app settings (key/value, cached).
-//
-// One instance is one owner (Robins besluit, 31-7-2026). The old tenancy modes
-// (hub = many artists on one domain, circle) are gone, code and all: the
-// branches were already unreachable and have now been deleted.
-//
-// The cache is updated immediately on setSetting, so a toggle in admin
-// takes effect live without a restart.
-
-import db from '../config/database.js';
-
-let _cache = null;
-
-function load() {
-  if (!_cache) {
-    _cache = {};
-    for (const r of db.prepare('SELECT key, value FROM app_settings').all()) {
-      _cache[r.key] = r.value;
-    }
-  }
-  return _cache;
-}
-
-export function getSetting(key, fallback = null) {
-  const v = load()[key];
-  return v === undefined ? fallback : v;
-}
-
-export function setSetting(key, value) {
-  db.prepare(`
-    INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
-    ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP
-  `).run(key, String(value));
-  if (_cache) _cache[key] = String(value);
-}
-
-// ActivityPub / fediverse federation. ON by default. '0' = off: the site does
-// not federate, /ap/* is gone, and the "from the fediverse" reactions disappear
-// — which (since native comments were removed) means no comments at all.
-export function apEnabled() {
-  return getSetting('ap_enabled', '1') !== '0';
-}
Index: src/services/SqliteSessionStore.js
===================================================================
--- src/services/SqliteSessionStore.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/services/SqliteSessionStore.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -35,8 +35,8 @@
     this._stmtDestroy = db.prepare('DELETE FROM sessions WHERE sid = ?');
     this._stmtTouch = db.prepare('UPDATE sessions SET expiresAt = ? WHERE sid = ?');
-    this._stmtCount = db.prepare("SELECT COUNT(*) AS c FROM sessions WHERE datetime(expiresAt) > datetime('now')");
+    this._stmtCount = db.prepare("SELECT COUNT(*) AS c FROM sessions WHERE expiresAt > datetime('now')");
     this._stmtClear = db.prepare('DELETE FROM sessions');
-    this._stmtAll = db.prepare("SELECT sid, data FROM sessions WHERE datetime(expiresAt) > datetime('now')");
-    this._stmtGc = db.prepare("DELETE FROM sessions WHERE datetime(expiresAt) <= datetime('now')");
+    this._stmtAll = db.prepare("SELECT sid, data FROM sessions WHERE expiresAt > datetime('now')");
+    this._stmtGc = db.prepare("DELETE FROM sessions WHERE expiresAt <= datetime('now')");
   }
 
Index: src/services/StatsService.js
===================================================================
--- src/services/StatsService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,186 +1,0 @@
-// StatsService — cookie-free statistics (premium module).
-//
-// Counters: posts.view_count, audio_tracks.play_count, and per day/site the number
-// of pageviews (stat_daily) + unique visitors (stat_visitor_day).
-//
-// Unique visitors WITHOUT cookies: a sha256 of IP+UA+daily-salt. The salt rotates
-// every day and is never stored longer → you cannot track someone across days,
-// and the raw IP is never persisted. No persistent identifier, no consent
-// banner required (Plausible/Fathom approach).
-
-import crypto from 'node:crypto';
-import db from '../config/database.js';
-import { getSetting, setSetting } from './SettingsService.js';
-
-function today() {
-  return new Date().toISOString().slice(0, 10); // YYYY-MM-DD (UTC)
-}
-
-// Daily rotating salt (cached in process, persisted in app_settings so that
-// a restart within the same day reuses the same salt).
-let _salt = null, _saltDay = null;
-function dailySalt() {
-  const d = today();
-  if (_salt && _saltDay === d) return _salt;
-  let stored = getSetting('stat_salt', null);
-  if (!stored || getSetting('stat_salt_day', null) !== d) {
-    stored = crypto.randomBytes(16).toString('hex');
-    setSetting('stat_salt', stored);
-    setSetting('stat_salt_day', d);
-  }
-  _salt = stored; _saltDay = d;
-  return stored;
-}
-
-function visitorHash(req) {
-  const ip = (req && (req.ip || (req.socket && req.socket.remoteAddress))) || '';
-  const ua = (req && req.headers && req.headers['user-agent']) || '';
-  return crypto.createHash('sha256').update(dailySalt() + '|' + ip + '|' + ua).digest('hex').slice(0, 32);
-}
-
-// Don't count the owner/admin — otherwise you inflate your own numbers.
-function isOperator(req) {
-  const u = req && req.session && req.session.user;
-  return !!(u && (u.role === 'god' || u.role === 'admin'));
-}
-
-// Skip known bots/crawlers + link-preview fetchers + scripts so they don't inflate
-// view/visitor-day counts. Empty UA = almost always automated.
-const BOT_RE = /bot|crawl|spider|slurp|mediapartners|bingpreview|facebookexternalhit|whatsapp|telegram|discord|twitter|linkedin|embedly|pinterest|redditbot|applebot|petalbot|yandex|baidu|duckduckbot|semrush|ahrefs|mj12|dotbot|uptimerobot|pingdom|statuscake|headless|lighthouse|gptbot|claude|ccbot|perplexity|bytespider|amazonbot|googleother|google-read-aloud|python-requests|scrapy|curl|wget|axios|node-fetch|go-http|java\/|okhttp|libwww|httpclient|mastodon|pleroma|akkoma|misskey|calckey|firefish|friendica|hubzilla|lemmy|pixelfed|peertube|gotosocial|honk|http\.rb|activitypub|klonkt/i;
-function isBot(req) {
-  const ua = (req && req.headers && req.headers['user-agent']) || '';
-  if (!ua) return true;          // empty UA = script/bot
-  // A signed request or an ActivityPub content-negotiation is BY DEFINITION a
-  // server fetching, not a reader (Robins vraag, 31-7): one boosted post made
-  // every fediverse instance's link-preview fetch count as a unique visitor.
-  const h = (req && req.headers) || {};
-  if (h['signature']) return true;
-  if (/application\/(activity|ld)\+json/i.test(String(h['accept'] || ''))) return true;
-  return BOT_RE.test(ua);
-}
-
-// The client IP (trust-proxy gives the real one), normalised: drop an IPv4-mapped-IPv6 prefix
-// and a trailing :port so it matches what the admin sees + stores.
-function clientIp(req) {
-  let ip = (req && (req.ip || (req.socket && req.socket.remoteAddress))) || '';
-  if (ip.startsWith('::ffff:')) ip = ip.slice(7);
-  if (/^\d{1,3}(\.\d{1,3}){3}:\d+$/.test(ip)) ip = ip.split(':')[0];
-  return ip;
-}
-export function currentIp(req) { return clientIp(req); }
-
-// Admin-configured IPs to skip — so an owner browsing logged-OUT (incognito, another browser)
-// doesn't inflate their own stats. Stored as a comma-separated app_setting.
-export function getExcludedIps() {
-  return (getSetting('stats_exclude_ips', '') || '').split(',').map((s) => s.trim()).filter(Boolean);
-}
-export function setExcludedIps(list) {
-  const clean = [...new Set((list || []).map((s) => String(s).trim()).filter(Boolean))].slice(0, 20);
-  setSetting('stats_exclude_ips', clean.join(','));
-}
-function isExcludedIp(req) {
-  try { const ip = clientIp(req); return !!ip && getExcludedIps().includes(ip); } catch { return false; }
-}
-
-// Lazy prepares — tables only exist after initializeDatabase(); this module is
-// imported before that call.
-let _s = null;
-function stmts() {
-  if (_s) return _s;
-  _s = {
-    bumpDaily: db.prepare(`
-      INSERT INTO stat_daily (site_id, day, pageviews) VALUES (?, ?, 1)
-      ON CONFLICT(site_id, day) DO UPDATE SET pageviews = pageviews + 1
-    `),
-    addVisitor: db.prepare('INSERT OR IGNORE INTO stat_visitor_day (site_id, day, visitor_hash) VALUES (?, ?, ?)'),
-    bumpPost: db.prepare('UPDATE posts SET view_count = COALESCE(view_count, 0) + 1 WHERE id = ?'),
-    bumpTrack: db.prepare('UPDATE audio_tracks SET play_count = COALESCE(play_count, 0) + 1 WHERE id = ?'),
-    bumpReferrer: db.prepare(`
-      INSERT INTO stat_referrer (site_id, host, count) VALUES (?, ?, 1)
-      ON CONFLICT(site_id, host) DO UPDATE SET count = count + 1
-    `),
-  };
-  return _s;
-}
-
-// External referrer host from the Referer header (pro stats #5). Empty/own-site/
-// invalid referrers are skipped → only genuine external sources are counted.
-function recordReferrer(siteId, req) {
-  try {
-    const ref = req && req.headers && (req.headers.referer || req.headers.referrer);
-    if (!ref) return;
-    const host = new URL(ref).host.replace(/^www\./, '').toLowerCase();
-    if (!host) return;
-    const own = ((req.headers && req.headers.host) || '').replace(/^www\./, '').toLowerCase();
-    if (host === own) return; // internal navigation does not count as a source
-    stmts().bumpReferrer.run(siteId, host.slice(0, 120));
-  } catch { /* not a valid referrer URL → skip */ }
-}
-
-export function recordPageview(siteId, req) {
-  if (!siteId || isOperator(req) || isBot(req) || isExcludedIp(req)) return;
-  try {
-    const d = today();
-    stmts().bumpDaily.run(siteId, d);
-    stmts().addVisitor.run(siteId, d, visitorHash(req));
-    recordReferrer(siteId, req);
-  } catch { /* stats must never break a request */ }
-}
-
-export function recordPostView(post, req) {
-  if (!post || !post.id || isOperator(req) || isBot(req) || isExcludedIp(req)) return;
-  try {
-    stmts().bumpPost.run(post.id);
-    recordPageview(post.site_id, req);
-  } catch {}
-}
-
-export function recordPlay(trackId) {
-  if (!trackId) return;
-  try { stmts().bumpTrack.run(trackId); } catch {}
-}
-
-// Instance-wide statistics (solo = the site, hub = all sites combined).
-export function getStats(days = 14) {
-  days = [7, 14, 30, 90].includes(Number(days)) ? Number(days) : 14;
-  const pvMap = Object.fromEntries(
-    db.prepare('SELECT day, SUM(pageviews) AS pv FROM stat_daily GROUP BY day').all().map((r) => [r.day, r.pv]),
-  );
-  const visMap = Object.fromEntries(
-    db.prepare('SELECT day, COUNT(*) AS v FROM stat_visitor_day GROUP BY day').all().map((r) => [r.day, r.v]),
-  );
-  const series = [];
-  for (let i = days - 1; i >= 0; i--) {
-    const dt = new Date();
-    dt.setUTCDate(dt.getUTCDate() - i);
-    const d = dt.toISOString().slice(0, 10);
-    series.push({ day: d, pageviews: pvMap[d] || 0, visitors: visMap[d] || 0 });
-  }
-  const totals = {
-    pageviews: series.reduce((s, r) => s + r.pageviews, 0), // last N days
-    visitors: series.reduce((s, r) => s + r.visitors, 0),   // sum of daily uniques (cookieless has no alternative)
-    plays: db.prepare('SELECT COALESCE(SUM(play_count), 0) AS n FROM audio_tracks').get().n,
-    postViews: db.prepare('SELECT COALESCE(SUM(view_count), 0) AS n FROM posts').get().n,
-  };
-  const topPosts = db.prepare(`
-    SELECT title, slug, COALESCE(view_count, 0) AS views FROM posts
-    WHERE status = 'published' ORDER BY view_count DESC, published_at DESC LIMIT 5
-  `).all();
-  const topTracks = db.prepare(`
-    SELECT title, COALESCE(play_count, 0) AS plays FROM audio_tracks
-    ORDER BY play_count DESC LIMIT 5
-  `).all();
-  // Top external sources (pro #5) — aggregated instance-wide per host.
-  let referrers = [];
-  try {
-    referrers = db.prepare(
-      'SELECT host, SUM(count) AS n FROM stat_referrer GROUP BY host ORDER BY n DESC LIMIT 10'
-    ).all();
-  } catch { referrers = []; }
-  // All-time totals (cookieless unique visitors = sum of daily uniques).
-  const allTime = {
-    pageviews: db.prepare('SELECT COALESCE(SUM(pageviews),0) AS n FROM stat_daily').get().n,
-    visitorDays: db.prepare('SELECT COUNT(*) AS n FROM stat_visitor_day').get().n,
-  };
-  return { totals, series, topPosts, topTracks, referrers, allTime, days };
-}
Index: src/services/SubscriberService.js
===================================================================
--- src/services/SubscriberService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,83 +1,0 @@
-/**
- * SubscriberService — newsletter subscribers per site (premium feature #1).
- *
- * Double opt-in when SMTP is configured (status 'pending' → 'confirmed' via
- * confirm link), otherwise single opt-in ('confirmed' immediately). Each
- * subscriber has a token used for both the confirm and unsubscribe links.
- * Reused by #2 (download-for-email) and #8 (notify-me) as shared subscriber storage.
- */
-
-import crypto from 'crypto';
-import db from '../config/database.js';
-import { v4 as uuid } from 'uuid';
-
-const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
-
-export function isValidEmail(email) {
-  return typeof email === 'string' && email.length <= 254 && EMAIL_RE.test(email.trim());
-}
-
-function newToken() {
-  return crypto.randomBytes(24).toString('hex');
-}
-
-/**
- * Add a subscriber (or reactivate an unsubscribed/existing one).
- * @returns {{ok:boolean, status?:string, token?:string, created?:boolean, error?:string}}
- *   status 'pending'  → confirmation still required (send confirm email)
- *   status 'confirmed'→ immediately active (single opt-in)
- */
-export function addSubscriber(siteId, email, source = 'widget', { doubleOptin = false } = {}) {
-  email = (email || '').trim().toLowerCase();
-  if (!siteId) return { ok: false, error: 'no_site' };
-  if (!isValidEmail(email)) return { ok: false, error: 'invalid_email' };
-
-  const existing = db.prepare('SELECT * FROM subscribers WHERE site_id = ? AND email = ?').get(siteId, email);
-  const status = doubleOptin ? 'pending' : 'confirmed';
-
-  if (existing) {
-    // Already confirmed → nothing to do (idempotent, no duplicate email).
-    if (existing.status === 'confirmed') return { ok: true, status: 'confirmed', token: existing.token, created: false };
-    // Pending or unsubscribed → re-invite/reactivate with a fresh token.
-    const token = newToken();
-    db.prepare("UPDATE subscribers SET status = ?, token = ?, source = ?, confirmed_at = CASE WHEN ? = 'confirmed' THEN CURRENT_TIMESTAMP ELSE NULL END WHERE id = ?")
-      .run(status, token, source, status, existing.id);
-    return { ok: true, status, token, created: false };
-  }
-
-  const token = newToken();
-  db.prepare(
-    "INSERT INTO subscribers (id, site_id, email, status, source, token, confirmed_at) VALUES (?,?,?,?,?,?, CASE WHEN ? = 'confirmed' THEN CURRENT_TIMESTAMP ELSE NULL END)"
-  ).run(uuid(), siteId, email, status, source, token, status);
-  return { ok: true, status, token, created: true };
-}
-
-export function confirm(token) {
-  if (!token) return false;
-  const row = db.prepare('SELECT id FROM subscribers WHERE token = ?').get(token);
-  if (!row) return false;
-  db.prepare("UPDATE subscribers SET status = 'confirmed', confirmed_at = CURRENT_TIMESTAMP WHERE id = ?").run(row.id);
-  return true;
-}
-
-export function unsubscribe(token) {
-  if (!token) return false;
-  const row = db.prepare('SELECT id FROM subscribers WHERE token = ?').get(token);
-  if (!row) return false;
-  db.prepare("UPDATE subscribers SET status = 'unsub' WHERE id = ?").run(row.id);
-  return true;
-}
-
-/** Confirmed subscribers (email + token) for a site — for sending newsletters.
- * Optionally filter by source (e.g. 'notify' for show announcements). */
-export function confirmedFor(siteId, source) {
-  if (source) {
-    return db.prepare("SELECT email, token FROM subscribers WHERE site_id = ? AND status = 'confirmed' AND source = ?").all(siteId, source);
-  }
-  return db.prepare("SELECT email, token FROM subscribers WHERE site_id = ? AND status = 'confirmed'").all(siteId);
-}
-
-export function counts(siteId) {
-  const c = (st) => db.prepare('SELECT COUNT(*) AS n FROM subscribers WHERE site_id = ? AND status = ?').get(siteId, st).n;
-  return { confirmed: c('confirmed'), pending: c('pending'), unsub: c('unsub') };
-}
Index: src/services/ThemeService.js
===================================================================
--- src/services/ThemeService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/services/ThemeService.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -2,59 +2,58 @@
  * ThemeService — Palette and theme management
  * 
- * 8 Built-in Palettes — 1 neutral default + 7 real colours:
- * - Klonkt (DEFAULT, clean white/black neutral + gold accent; was 'Paper')
- * - Forest (green) · Ocean (blue) · Teal · Lilac (purple)
- * - Sunset (pink) · Candy (red) · Amber (warm)
- * Colour palettes are tinted (like forest/lilac), not navy-with-accent.
- *
+ * 8 Built-in Palettes (from v9):
+ * - Sage (default, warm crème)
+ * - Paper (minimalist white)
+ * - Ocean (cool blues)
+ * - Forest (greens)
+ * - Stone (grays)
+ * - Midnight (dark blue)
+ * - Sunset (warm oranges)
+ * - Cream (light beige)
+ * 
  * Dark/Light mode toggle stored per user
  */
 
 class ThemeService {
-  // Paper/ink values map 1-to-1 from the [data-palette] CSS in style.css (= what
-  // is ACTUALLY applied); the accent dot is a representative color per palette.
   static PALETTES = {
-    // DEFAULT — clean neutral (formerly 'Paper'), renamed to the brand 'Klonkt'.
-    // White → near-black, with the brand gold as accent. The old navy 'Klonkt' is gone.
-    klonkt: {
-      name: 'Klonkt',
-      light: { paper: '#ffffff', ink: '#09090b', accent: '#c98a2a' },
-      dark: { paper: '#0a0a0a', ink: '#fafafa', accent: '#e8b04b' }
-    },
-    // 7 real-colour palettes (tinted paper, like forest/lilac — NOT navy-with-accent).
+    sage: {
+      name: 'Sage',
+      light: { paper: '#faf8f3', ink: '#1a1a1a', accent: '#c2410c' },
+      dark: { paper: '#1c1a17', ink: '#f4ede0', accent: '#c2410c' }
+    },
+    paper: {
+      name: 'Paper',
+      light: { paper: '#ffffff', ink: '#09090b', accent: '#000000' },
+      dark: { paper: '#09090b', ink: '#fafafa', accent: '#ffffff' }
+    },
+    ocean: {
+      name: 'Ocean',
+      light: { paper: '#f0f9ff', ink: '#0c2d48', accent: '#0369a1' },
+      dark: { paper: '#001f3f', ink: '#e0f2fe', accent: '#06b6d4' }
+    },
     forest: {
       name: 'Forest',
-      light: { paper: '#f2f6ed', ink: '#1a2e15', accent: '#4d7c2a' },
-      dark: { paper: '#0d1f12', ink: '#dcf2d0', accent: '#6fae3f' }
-    },
-    ocean: {
-      name: 'Ocean',
-      light: { paper: '#eef4fb', ink: '#0f2942', accent: '#1d6fe0' },
-      dark: { paper: '#081726', ink: '#d6e8fb', accent: '#5ba0f5' }
-    },
-    teal: {
-      name: 'Teal',
-      light: { paper: '#ecf7f5', ink: '#0c2e2a', accent: '#0d9488' },
-      dark: { paper: '#06201d', ink: '#d4f2ec', accent: '#2dd4bf' }
-    },
-    lilac: {
-      name: 'Lilac',
-      light: { paper: '#faf4fb', ink: '#2a1830', accent: '#a855f7' },
-      dark: { paper: '#170a1c', ink: '#f3e2f7', accent: '#c084fc' }
+      light: { paper: '#f0fdf4', ink: '#15803d', accent: '#16a34a' },
+      dark: { paper: '#052e16', ink: '#dcfce7', accent: '#22c55e' }
+    },
+    stone: {
+      name: 'Stone',
+      light: { paper: '#f5f5f5', ink: '#262626', accent: '#737373' },
+      dark: { paper: '#1f1f1f', ink: '#e5e5e5', accent: '#a3a3a3' }
+    },
+    midnight: {
+      name: 'Midnight',
+      light: { paper: '#f8fafc', ink: '#1e293b', accent: '#3b82f6' },
+      dark: { paper: '#0f172a', ink: '#f1f5f9', accent: '#60a5fa' }
     },
     sunset: {
       name: 'Sunset',
-      light: { paper: '#fdf4f3', ink: '#2e1618', accent: '#d6477f' },
-      dark: { paper: '#1f0a14', ink: '#fce7f3', accent: '#f06fa3' }
-    },
-    candy: {
-      name: 'Candy',
-      light: { paper: '#fdf1f3', ink: '#3a1018', accent: '#e11d48' },
-      dark: { paper: '#220810', ink: '#fde0e6', accent: '#fb6f8b' }
-    },
-    amber: {
-      name: 'Amber',
-      light: { paper: '#fdf6e9', ink: '#3a2a0c', accent: '#d97706' },
-      dark: { paper: '#221a08', ink: '#fdeecb', accent: '#f0a93a' }
+      light: { paper: '#fef3c7', ink: '#92400e', accent: '#f97316' },
+      dark: { paper: '#5a1f08', ink: '#fef3c7', accent: '#fb923c' }
+    },
+    cream: {
+      name: 'Cream',
+      light: { paper: '#fffbf0', ink: '#78350f', accent: '#d97706' },
+      dark: { paper: '#3f2305', ink: '#fffbf0', accent: '#f59e0b' }
     }
   };
@@ -65,17 +64,13 @@
    * Each color works against both light and dark themes.
    */
-  // Balanced across the color wheel — fewer greens/blues (4 of 12),
-  // more warm + purple/pink variation. All readable on both light and dark.
   static ACCENTS = [
-    { key: 'klonkt',  name: 'Klonkt-geel', color: '#e8b04b' },
-    { key: 'red',     name: 'Candy-rood', color: '#e11d48' },
-    { key: 'amber',   name: 'Amber',     color: '#d97706' },
-    { key: 'forest',  name: 'Groen',     color: '#16a34a' },
-    { key: 'teal',    name: 'Turquoise', color: '#0d9488' },
-    { key: 'ocean',   name: 'Blauw',     color: '#2563eb' },
-    { key: 'indigo',  name: 'Indigo',    color: '#4f46e5' },
-    { key: 'violet',  name: 'Violet',    color: '#7c3aed' },
-    { key: 'plum',    name: 'Magenta',   color: '#c026d3' },
-    { key: 'pink',    name: 'Roze',      color: '#db2777' },
+    { key: 'orange',  name: 'Oranje',  color: '#c2410c' },
+    { key: 'sage',    name: 'Salie',   color: '#5a8a5a' },
+    { key: 'ocean',   name: 'Oceaan',  color: '#0369a1' },
+    { key: 'forest',  name: 'Bos',     color: '#16a34a' },
+    { key: 'plum',    name: 'Pruim',   color: '#9d3a78' },
+    { key: 'gold',    name: 'Goud',    color: '#d97706' },
+    { key: 'crimson', name: 'Karmijn', color: '#ef2840' },
+    { key: 'indigo',  name: 'Indigo',  color: '#6366f1' },
   ];
 
@@ -100,5 +95,5 @@
    */
   static getPalette(paletteKey) {
-    return this.PALETTES[paletteKey] || this.PALETTES.klonkt;
+    return this.PALETTES[paletteKey] || this.PALETTES.sage;
   }
 
@@ -172,6 +167,6 @@
   static generateThemeMeta(userTheme, userPalette, siteTheme, siteAccent) {
     const theme = userTheme || siteTheme || 'dark';
-    const palette = userPalette || 'klonkt';
-    const accent = siteAccent || '#e8b04b';
+    const palette = userPalette || 'sage';
+    const accent = siteAccent || '#c2410c';
     const paletteData = this.getPalette(palette);
     const colors = theme === 'dark' ? paletteData.dark : paletteData.light;
Index: src/services/ThumbnailService.js
===================================================================
--- src/services/ThumbnailService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,251 +1,0 @@
-/**
- * On-demand cover thumbnails.
- *
- * High-res covers (especially line-art) look jagged because the BROWSER downscales
- * them to the small grid/list size. We instead downscale the stored original
- * server-side with ffmpeg's lanczos filter to a small WebP and cache it on disk, so
- * the browser receives a near-1:1 image → crisp lines.
- *
- * No re-upload / backfill: the original file is only READ, never modified, and
- * thumbnails are generated lazily on first request. Cover filenames are content-
- * hashed/UUID, so a cached thumbnail can never go stale (a new cover = a new name).
- *
- * Uses the bundled `ffmpeg-static` (always present); cwebp is not required here.
- */
-import { execFile } from 'child_process';
-import ffmpegPath from 'ffmpeg-static';
-import path from 'path';
-import fs from 'fs';
-import crypto from 'crypto';
-import { promisify } from 'util';
-import { safeFetch } from './ActivityPubService.js';
-
-const execFileP = promisify(execFile);
-
-// Allowed widths (whitelist → no arbitrary-size abuse). 96 = small feed/comment avatars
-// (~44px); 128 = nav/profile avatars; 256 ≈ 2× a list cover; 480 ≈ 2× a grid tile; 1280 =
-// full-width timeline media (crisp on mobile retina, ~430px × 3 DPR). Keep these ~2× the
-// display size so the browser barely scales (avoids both jaggies and upscaling blur).
-export const THUMB_SIZES = new Set([96, 128, 256, 320, 480, 640, 1280]);
-
-let _seq = 0;
-
-// Limit concurrent ffmpeg spawns. A cold-cache, image-heavy page fires many thumbnail
-// requests at once; without a cap each spawns its own ffmpeg → CPU saturation makes the
-// WHOLE instance slow (the thundering herd). With the cap, excess requests wait briefly
-// for a slot → bounded CPU, the page still loads (images just appear progressively).
-const MAX_CONCURRENT = 3;
-let _active = 0;
-const _waiters = [];
-function acquireSlot() {
-  if (_active < MAX_CONCURRENT) { _active++; return Promise.resolve(); }
-  return new Promise((resolve) => _waiters.push(resolve));
-}
-function releaseSlot() {
-  const next = _waiters.shift();
-  if (next) next();      // transfer the slot directly to the next waiter (_active unchanged)
-  else _active--;
-}
-async function runFfmpeg(args) {
-  await acquireSlot();
-  try { await execFileP(ffmpegPath, args, { timeout: 20000 }); }
-  finally { releaseSlot(); }
-}
-
-function mediaRoot() {
-  return path.resolve(process.env.MEDIA_PATH || './storage/media');
-}
-
-// Resolve a safe absolute path for a relative media path; null on traversal attempts.
-function safeOriginal(rel) {
-  const root = mediaRoot();
-  const orig = path.resolve(root, rel);
-  if (orig !== root && !orig.startsWith(root + path.sep)) return null;
-  return orig;
-}
-
-// Is the source an animated image (animated WebP or GIF)? If so the thumbnail must keep ALL
-// frames (a downscaled animated WebP) instead of grabbing a single frame — otherwise an
-// animated cover shows up frozen on the site.
-function isAnimatedSrc(filePath) {
-  try {
-    const ext = path.extname(filePath).toLowerCase();
-    if (ext === '.gif') return true; // a flattened GIF would lose its animation too
-    if (ext !== '.webp') return false;
-    const fd = fs.openSync(filePath, 'r');
-    try {
-      const buf = Buffer.alloc(40);
-      const n = fs.readSync(fd, buf, 0, 40, 0);
-      // RIFF…WEBP, then a VP8X chunk (bytes 12-15) whose flags byte (20) has the animation bit.
-      return n >= 21 && buf.toString('ascii', 12, 16) === 'VP8X' && (buf[20] & 0x02) !== 0;
-    } finally { fs.closeSync(fd); }
-  } catch { return false; }
-}
-
-/**
- * Return the on-disk path of the cached thumbnail, generating it if needed.
- * @returns {Promise<string|null>} absolute path, or null if it can't be produced.
- */
-export async function getThumbnail(rel, width) {
-  if (!THUMB_SIZES.has(width) || !ffmpegPath || !rel) return null;
-  const orig = safeOriginal(rel);
-  if (!orig || !fs.existsSync(orig)) return null;
-
-  const root = mediaRoot();
-  // Cache under <media>/.thumbs/<w>/<rel>.webp (dotted dir → never collides with media).
-  const cached = path.join(root, '.thumbs', String(width), rel) + '.webp';
-  if (fs.existsSync(cached)) return cached;
-
-  // ffmpeg-static can't decode an animated WebP ("image data not found"), so we can't make a
-  // scaled animated thumbnail. Return null → the route serves the ORIGINAL instead, which keeps
-  // animating. (Animated covers are usually already small, so skipping the downscale is fine.)
-  if (isAnimatedSrc(orig)) return null;
-
-  await fs.promises.mkdir(path.dirname(cached), { recursive: true });
-  const tmp = `${cached}.tmp-${process.pid}-${_seq++}`;
-  try {
-    await runFfmpeg([
-      '-hide_banner', '-loglevel', 'error', '-y',
-      '-i', orig,
-      // Downscale to `width` (never upscale past the original) with lanczos; even height.
-      '-vf', `scale='min(${width},iw)':-2:flags=lanczos`,
-      '-frames:v', '1',
-      '-c:v', 'libwebp', '-q:v', '82',
-      // Force the WebP muxer: the tmp filename has no .webp extension, so ffmpeg
-      // can't infer the output format from it.
-      '-f', 'webp',
-      tmp,
-    ]);
-    await fs.promises.rename(tmp, cached);
-    return cached;
-  } catch (e) {
-    try { await fs.promises.unlink(tmp); } catch {}
-    console.warn('[thumb] generation failed for', rel, '-', e.message);
-    return null;
-  }
-}
-
-// ── Signed remote-image proxy ─────────────────────────────────────
-// Remote avatars/images (fediverse) live on OTHER servers, so we fetch them once
-// (SSRF-safe via safeFetch), downscale them identically, and cache. The proxy URL is
-// HMAC-signed so it can't be abused as an open image-resizer: only URLs that Klonkt
-// itself rendered are accepted.
-
-let _key;
-function imgKey() {
-  if (_key) return _key;
-  _key = process.env.SESSION_SECRET || '';
-  if (!_key) {
-    try {
-      const dataDir = path.dirname(path.resolve(process.env.DATABASE_PATH || './storage/database.sqlite'));
-      _key = fs.readFileSync(path.join(dataDir, '.session-secret'), 'utf8').trim();
-    } catch { _key = 'klonkt-img-proxy'; }
-  }
-  return _key;
-}
-
-function sign(url, w) {
-  return crypto.createHmac('sha256', imgKey()).update(`${w}:${url}`).digest('hex').slice(0, 24);
-}
-
-// Signed proxy URL for a remote image (used by the avatar() view helper).
-export function imgProxyUrl(url, width) {
-  return `/img/a/${width}?u=${encodeURIComponent(url)}&s=${sign(url, width)}`;
-}
-
-export function verifyImg(url, width, sig) {
-  if (!sig || !url) return false;
-  let want;
-  try { want = sign(url, width); } catch { return false; }
-  try { return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want)); } catch { return false; }
-}
-
-/**
- * Fetch a remote image (SSRF-safe), downscale to `width` (lanczos → WebP), cache it.
- * @returns {Promise<string|null>} cached path, or null.
- */
-// Same animation check as isAnimatedSrc but on an in-memory buffer (remote fetch): ffmpeg-static
-// can't decode an animated WebP, and flattening a GIF/animated WebP to one frame loses its motion.
-function isAnimatedBuf(buf) {
-  try {
-    if (!buf || buf.length < 21) return false;
-    if (buf.toString('ascii', 0, 3) === 'GIF') return true; // any GIF (a flattened one loses its animation)
-    // Animated WebP: RIFF…WEBP with a VP8X chunk whose flags byte (20) has the animation bit (0x02).
-    if (buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP'
-        && buf.toString('ascii', 12, 16) === 'VP8X' && (buf[20] & 0x02) !== 0) return true;
-    return false;
-  } catch { return false; }
-}
-export async function getRemoteThumbnail(url, width) {
-  if (!THUMB_SIZES.has(width) || !ffmpegPath || !url) return null;
-  const root = mediaRoot();
-  const hash = crypto.createHash('sha256').update(url).digest('hex');
-  // Remote filenames are content-hashed (Mastodon/Klonkt) → URL-keyed cache never stales.
-  const cached = path.join(root, '.thumbs', 'remote', String(width), `${hash}.webp`);
-  if (fs.existsSync(cached)) return cached;
-
-  let buf, isVideo = false;
-  try {
-    const r = await safeFetch(url);
-    if (!r.ok) return null;
-    const ct = r.headers.get('content-type') || '';
-    isVideo = ct.startsWith('video/');
-    if (!ct.startsWith('image/') && !isVideo) return null;
-    if (isVideo) {
-      // Remote video → poster frame (feed/tile posters). A bounded head (first 4MB) only decodes
-      // a faststart mp4 (moov atom up front). Many platforms (Loops.video, phone exports) put the
-      // moov atom at the END, so a head-only fetch fails → no thumbnail. So: if the file is small
-      // enough (content-length within VIDEO_CAP) grab it WHOLE — that works regardless of moov
-      // position. Only when the size is unknown or very large do we fall back to a bounded head
-      // (best-effort; a large moov-at-end file still yields null → the route's 302 fallback).
-      const VIDEO_CAP = 64 * 1024 * 1024; // 64MB — covers short-form clips incl. moov-at-end
-      const clen = parseInt(r.headers.get('content-length') || '0', 10);
-      try { if (r.body && r.body.cancel) r.body.cancel(); } catch { /* ignore */ }
-      let rv;
-      if (clen && clen <= VIDEO_CAP) {
-        rv = await safeFetch(url);
-        if (!rv.ok) return null;
-      } else {
-        rv = await safeFetch(url, { headers: { Range: 'bytes=0-8388607' } }); // 8MB head fallback
-        if (!rv.ok && rv.status !== 206) return null;
-      }
-      buf = Buffer.from(await rv.arrayBuffer());
-      if (!buf.length || buf.length > VIDEO_CAP) return null;
-    } else {
-      if (parseInt(r.headers.get('content-length') || '0', 10) > 12 * 1024 * 1024) return null;
-      buf = Buffer.from(await r.arrayBuffer());
-    }
-  } catch (e) {
-    console.warn('[thumb-remote] fetch failed for', url, '-', e.message);
-    return null;
-  }
-  if (!isVideo && buf.length > 12 * 1024 * 1024) return null; // video already capped at VIDEO_CAP
-  // ffmpeg-static can't decode an animated WebP (the doomed downscale just logs an error), and a
-  // flattened GIF/animated WebP loses its motion → skip it and let the route serve the ORIGINAL
-  // (keeps the animation; mirrors the local path's isAnimatedSrc guard). Video heads skip this
-  // (they're not webp/gif) and go straight to the single-frame extract.
-  if (!isVideo && isAnimatedBuf(buf)) return null;
-
-  await fs.promises.mkdir(path.dirname(cached), { recursive: true });
-  const tmpIn = `${cached}.in-${process.pid}-${_seq++}`;
-  const tmpOut = `${cached}.out-${process.pid}-${_seq++}`;
-  try {
-    await fs.promises.writeFile(tmpIn, buf);
-    await runFfmpeg([
-      '-hide_banner', '-loglevel', 'error', '-y',
-      '-i', tmpIn,
-      '-vf', `scale='min(${width},iw)':-2:flags=lanczos`,
-      '-frames:v', '1',
-      '-c:v', 'libwebp', '-q:v', '82', '-f', 'webp',
-      tmpOut,
-    ]);
-    await fs.promises.rename(tmpOut, cached);
-    return cached;
-  } catch (e) {
-    console.warn('[thumb-remote] downscale failed for', url, '-', e.message);
-    return null;
-  } finally {
-    fs.promises.unlink(tmpIn).catch(() => {});
-    fs.promises.unlink(tmpOut).catch(() => {});
-  }
-}
Index: src/services/VideoCoverService.js
===================================================================
--- src/services/VideoCoverService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,141 +1,0 @@
-/**
- * VideoCoverService — turn an animated cover into a small, Safari-friendly muted loop video.
- *
- * Safari renders animated WebP poorly; a muted <video> loop plays smoothly everywhere (iOS too).
- * ffmpeg-static can't DECODE an animated WebP, so we decode it with node-webpmux (pure JS/WASM,
- * NO native deps → installs on every platform, never breaks `npm ci`) into RGBA frames, then
- * encode with the bundled ffmpeg-static into an H.264 MP4 (yuv420p + faststart + no audio). A real
- * uploaded video goes straight through ffmpeg. Both are best-effort: on any failure (or an oversized
- * input) we return null and the caller keeps the still image.
- */
-import { execFile } from 'child_process';
-import { promisify } from 'util';
-import fs from 'fs';
-import path from 'path';
-import ffmpegPath from 'ffmpeg-static';
-import WebP from 'node-webpmux';
-
-const execFileP = promisify(execFile);
-const MAX_SECONDS = 60;            // cap a cover loop at one minute
-const MAX_FRAMES = 600;            // skip a pathological animated WebP (keep the still image)
-const MAX_WORK = 250_000_000;      // W*H*frames cap — bounds compositor memory churn + CPU/time
-
-let _lib = null;
-function ensureLib() { if (!_lib) _lib = WebP.Image.initLib(); return _lib; }
-
-// node-webpmux's getFrameData(i) returns ONLY frame i's own sub-region (x,y,width,height) — it does
-// NOT composite onto the canvas. Real (tool-made) animated WebPs use partial frames of varying size,
-// so we composite each onto a persistent W×H canvas (honoring blend + dispose) and STREAM consistent
-// full frames straight to `rawPath` — one canvas in memory, not all N frames. Feeding ffmpeg the raw
-// varying-size sub-regions desyncs the stream → a torn/tiled video.
-async function compositeFramesToFile(img, rawPath) {
-  const W = img.width, H = img.height, frames = img.anim.frames;
-  const canvas = Buffer.alloc(W * H * 4); // transparent black
-  const fd = fs.openSync(rawPath, 'w');
-  try {
-    let prev = null; // previous frame's rect + dispose
-    for (let i = 0; i < frames.length; i++) {
-      const fr = frames[i];
-      if (prev && prev.dispose) { // dispose-to-background: clear the previous frame's rect first
-        for (let row = 0; row < prev.h; row++) {
-          const y = prev.y + row; if (y < 0 || y >= H) continue;
-          canvas.fill(0, (y * W + prev.x) * 4, (y * W + prev.x + prev.w) * 4);
-        }
-      }
-      const data = Buffer.from(await img.getFrameData(i)); // fr.width*fr.height*4 RGBA sub-region
-      // node-webpmux returns the raw ANMF offset, which the WebP spec stores as actual/2 (frame
-      // offsets are always even); libwebp/webpmux double it. So ×2 the x/y to get the true pixel
-      // position — else partial frames land at half-offset and ghost over the base. width/height are fine.
-      const fx = fr.x * 2, fy = fr.y * 2, fw = fr.width, fh = fr.height, blend = fr.blend;
-      if (fx === 0 && fy === 0 && fw === W && fh === H && !blend) {
-        data.copy(canvas, 0); // full opaque overwrite (the typical base frame)
-      } else {
-        for (let row = 0; row < fh; row++) {
-          const cy = fy + row; if (cy < 0 || cy >= H) continue;
-          for (let col = 0; col < fw; col++) {
-            const cx = fx + col; if (cx < 0 || cx >= W) continue;
-            const s = (row * fw + col) * 4, d = (cy * W + cx) * 4, sa = data[s + 3];
-            if (!blend || sa === 255) { canvas[d] = data[s]; canvas[d + 1] = data[s + 1]; canvas[d + 2] = data[s + 2]; canvas[d + 3] = sa; }
-            else if (sa !== 0) { // alpha-over the existing canvas pixel
-              const a = sa / 255, ia = 1 - a;
-              canvas[d]     = (data[s]     * a + canvas[d]     * ia) | 0;
-              canvas[d + 1] = (data[s + 1] * a + canvas[d + 1] * ia) | 0;
-              canvas[d + 2] = (data[s + 2] * a + canvas[d + 2] * ia) | 0;
-              canvas[d + 3] = Math.min(255, sa + ((canvas[d + 3] * ia) | 0));
-            }
-          }
-        }
-      }
-      fs.writeSync(fd, canvas); // stream the full composited canvas (bounds memory to one frame)
-      prev = { x: fx, y: fy, w: fw, h: fh, dispose: fr.dispose };
-    }
-  } finally { fs.closeSync(fd); }
-}
-
-// True if the file is an animated WebP (a VP8X chunk with the animation flag set).
-export function isAnimatedWebp(filePath) {
-  try {
-    if (path.extname(filePath).toLowerCase() !== '.webp') return false;
-    const fd = fs.openSync(filePath, 'r');
-    try {
-      const b = Buffer.alloc(40);
-      const n = fs.readSync(fd, b, 0, 40, 0);
-      return n >= 21 && b.toString('ascii', 12, 16) === 'VP8X' && (b[20] & 0x02) !== 0;
-    } finally { fs.closeSync(fd); }
-  } catch { return false; }
-}
-
-// Animated WebP → muted loop MP4. Returns { videoPath } or null (caller keeps the still image).
-export async function animatedWebpToVideo(srcPath, outDir, baseName) {
-  let rawPath = null;
-  try {
-    if (!ffmpegPath) return null;
-    await ensureLib();
-    const img = new WebP.Image();
-    await img.load(srcPath);
-    if (!img.hasAnim || !img.anim || !Array.isArray(img.anim.frames) || img.anim.frames.length < 2) return null;
-    const W = img.width, H = img.height, n = img.anim.frames.length;
-    // Guard a pathologically large cover (memory/CPU): keep the still image instead of converting.
-    if (!W || !H || n > MAX_FRAMES || W * H * n > MAX_WORK) {
-      console.warn(`[videocover] cover too large to convert (${W}x${H}, ${n} frames) — keeping the still image`);
-      return null;
-    }
-    const fps = Math.max(1, Math.min(30, Math.round(1000 / (img.anim.frames[0].delay || 100))));
-    await fs.promises.mkdir(outDir, { recursive: true });
-    rawPath = path.join(outDir, baseName + '.rgba.tmp');
-    await compositeFramesToFile(img, rawPath); // streams full composited W×H frames to disk
-    const videoPath = path.join(outDir, baseName + '.mp4');
-    await execFileP(ffmpegPath, ['-hide_banner', '-loglevel', 'error',
-      '-f', 'rawvideo', '-pix_fmt', 'rgba', '-s', `${W}x${H}`, '-r', String(fps), '-i', rawPath,
-      '-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2', // yuv420p needs even dimensions
-      '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-an', '-y', videoPath],
-      { timeout: 60000 });
-    return { videoPath };
-  } catch (e) {
-    console.warn('[videocover] animated webp → mp4 failed:', e.message);
-    return null;
-  } finally {
-    if (rawPath) try { await fs.promises.unlink(rawPath); } catch { /* ignore */ }
-  }
-}
-
-// An uploaded video → muted loop MP4 (scaled ≤1280w, capped 60s). ffmpeg decodes every video format,
-// so no node-webpmux here. Returns { videoPath } or null.
-export async function videoToLoop(srcPath, outDir, baseName) {
-  try {
-    if (!ffmpegPath) return null;
-    await fs.promises.mkdir(outDir, { recursive: true });
-    const videoPath = path.join(outDir, baseName + '.mp4');
-    await execFileP(ffmpegPath, ['-hide_banner', '-loglevel', 'error',
-      '-i', srcPath, '-t', String(MAX_SECONDS),
-      '-vf', "scale='min(1280,iw)':-2,pad=ceil(iw/2)*2:ceil(ih/2)*2",
-      '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-an', '-y', videoPath],
-      { timeout: 120000 });
-    return { videoPath };
-  } catch (e) {
-    console.warn('[videocover] video → loop failed:', e.message);
-    return null;
-  }
-}
-
-export default { isAnimatedWebp, animatedWebpToVideo, videoToLoop };
Index: src/services/ap-c2s.js
===================================================================
--- src/services/ap-c2s.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,455 +1,0 @@
-/**
- * ap-c2s.js — de Client-to-Server-inname (stap 4 van shaer-drc).
- *
- * De C2S-tegenhanger van handleInbox: een eigen client (Shaer, R9999) POST een
- * activity op de outbox en dit blok vertaalt hem naar DEZELFDE machinerie die
- * het web gebruikt (deliverReply, sendInteraction, followActor, deliverCreate).
- *
- * Anders dan ap-transport is dit een COORDINATOR: hij roept de dienstlaag aan,
- * en de regel van shaer-drc verbiedt een import uit ActivityPubService.js.
- * Daarom het patroon dat guardianship al bewees: de dienstlaag geeft zijn
- * werktuigen bij het laden door via wireC2S, en de verhuisde functies staan
- * hier byte-voor-byte ongewijzigd -- ze merken niet dat hun buren injectie
- * werden. Wat WEL rechtstreeks geimporteerd wordt, wijst omlaag: db, ap-core,
- * de sanitizer en guardianship.
- */
-import crypto from 'crypto';
-import fs from 'fs';
-import path from 'path';
-import db from '../config/database.js';
-import HtmlSanitizerService from './HtmlSanitizerService.js';
-import * as Guardianship from './guardianship/index.js';
-import { PUBLIC, actorId } from './ap-core.js';
-
-// Dezelfde twee als de re-exports in ActivityPubService: het directe-note-been
-// en de zichtbaarheidsregel wonen in guardianship, hier alleen kortgesloten
-// zodat de verhuisde regels ongewijzigd blijven.
-const c2sVisibility = Guardianship.c2sVisibility;
-const deliverDirectNote = Guardianship.deliverDirectNote;
-
-// De werktuigen uit de dienstlaag. ActivityPubService vult ze onderaan zijn
-// eigen evaluatie met wireC2S -- ruim voordat er een verzoek kan binnenkomen.
-// Een aanroep VOOR de koppeling is een programmeerfout en mag hard vallen.
-let proposeGate, deriveHandle, resolveRemoteNote, deliverReply, markRead,
-  postIdFromNoteUrl, sendInteraction, setReaction, gateOutgoingFollow,
-  followActor, unfollowActor, blockTarget, unblock, deliverDelete,
-  deliverOutboxDelete, bakePostContent, bakePostContentWithMentions,
-  deliverCreate;
-export function wireC2S(deps) {
-  ({ proposeGate, deriveHandle, resolveRemoteNote, deliverReply, markRead,
-    postIdFromNoteUrl, sendInteraction, setReaction, gateOutgoingFollow,
-    followActor, unfollowActor, blockTarget, unblock, deliverDelete,
-    deliverOutboxDelete, bakePostContent, bakePostContentWithMentions,
-    deliverCreate } = deps);
-}
-
-// ── ActivityPub Client-to-Server: ingest an activity POSTed to the outbox ──
-// The C2S counterpart of handleInbox: a native/web client (Shaer) posts an
-// activity here and we translate it onto the SAME delivery machinery the web UI
-// uses (deliverReply / sendInteraction / followActor / deliverCreate). Returns
-// { status, id?, url?, error? }. Auth + site-ownership are checked by the route.
-const c2sIdOf = (x) => (typeof x === 'string' ? x : (x && (x.id || x.href))) || null;
-
-export async function ingestOutboxActivity(site, user, activity) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !site || !activity || typeof activity !== 'object') return { status: 400, error: 'invalid_activity' };
-
-  // AP §6: a client MAY POST a bare object; the server wraps it in a Create.
-  let type = activity.type;
-  let object = activity.object;
-  if (type === 'Note' || type === 'Article') { object = activity; type = 'Create'; }
-  if (Array.isArray(type)) type = type.find((t) => typeof t === 'string');
-
-  // FEP-633c: the adoption handshake (Offer/Accept/Reject on a guardianship
-  // Relationship) belongs to the guardianship module; anything else falls
-  // through to the switch below.
-  if (type === 'Offer' || type === 'Accept' || type === 'Reject') {
-    const g = await Guardianship.handleGuardianshipOutbox(site, activity).catch(() => null);
-    if (g) return g;
-  }
-  // Een gate-voorstel uit de app (5.6, shaer-8ru): een Offer van een
-  // shaer:GatedSetting, de vorm die 5.6 al beschrijft.
-  //
-  // HIER, EN GEEN `case` IN DE SWITCH. Dat was hij eerst, en die claimde ELKE
-  // Offer: wat geen gate-voorstel was kreeg 400 unsupported_offer -- ook de
-  // adoptie-handshake, en straks elke Offer-vorm die we nog toevoegen. Barts
-  // honderd aanbiedingen liepen er meteen op stuk. Alleen claimen wat je
-  // herkent, en de rest laten doorlopen.
-  if (type === 'Offer') {
-    const gs = Guardianship.gated.parseGatedSetting(activity.object);
-    if (gs) {
-      const uit = proposeGate(site, gs.ward, gs.feature, gs.value);
-      return uit.status === 200 ? { ...uit, status: 201, id: uit.offerId } : uit;
-    }
-  }
-
-  try {
-    switch (type) {
-      case 'Create': {
-        if (!object || typeof object !== 'object') return { status: 400, error: 'missing_object' };
-        // Innamepoorten (shaer-ahy.1, 8-8): wat de ward niet mag versturen
-        // wordt HIER geweigerd, niet in de app verstopt -- een knop die de
-        // client alleen verbergt is geen poort. De reddingsboei gaat ALTIJD
-        // voor: een hulpvraag aan de guardians mag door elke dichte deur heen,
-        // anders sluit een messages-poort precies het kanaal af dat het kind
-        // veilig houdt.
-        {
-          const isWard = (() => { try { return Guardianship.listGuardians(site.slug).length > 0; } catch { return false; } })();
-          const isHelp = object['shaer:helpRequest'] === true || object.helpRequest === true;
-          // Een poortverzoek van het kind zelf (shaer-8ru) gaat langs de
-          // messages-poort. Dat lijkt een gat en is het niet: het verzoek draagt
-          // ALLEEN de naam van de feature, geen vrije tekst, dus er ontstaat geen
-          // kanaal om omheen die poort te praten. Zonder deze uitzondering kan
-          // een kind met berichten dicht nergens meer om vragen -- en dan is de
-          // hele weg dood op precies het moment dat hij nodig is.
-          const isGateReq = !!Guardianship.gatereq.parseRequest(object);
-          const direct = c2sVisibility(object) === 'direct';
-          if (!isHelp && !isGateReq) {
-            if (direct && !Guardianship.wardGateAllowed(site.gate_messages, isWard)) {
-              return { status: 403, error: 'gated_messages' };
-            }
-            if (!direct && !object.inReplyTo && !Guardianship.wardGateAllowed(site.gate_compose, isWard)) {
-              return { status: 403, error: 'gated_compose' };
-            }
-            // Meedoen aan een gesprek is ook iets (Bart, 8-8). Hier stond de
-            // aanname dat een antwoord geen eigen podium is en dus onder compose
-            // door mocht. Dat is teruggedraaid: antwoorden heeft een EIGEN poort,
-            // los van compose in beide richtingen -- je kunt willen dat een kind
-            // meepraat zonder podium, en ook andersom.
-            //
-            // Geldt ook voor een DIRECT antwoord, bovenop de messages-poort: een
-            // privé-antwoord is allebei, en dan mag allebei hem tegenhouden.
-            if (object.inReplyTo && !Guardianship.wardGateAllowed(site.gate_replies, isWard)) {
-              return { status: 403, error: 'gated_replies' };
-            }
-          }
-        }
-        // Client sends `source` (plain/markdown) + `content` (HTML). deliverReply
-        // re-escapes, so it needs plain text; a top-level post keeps sanitized HTML.
-        const plain = (object.source && object.source.content) || HtmlSanitizerService.toPlainText(object.content || '');
-        // A picture (or a recording) can be the whole message: media-only
-        // notes pass here; c2sCreatePost validates the attachments themselves.
-        if (!plain.trim() && !object.content && !(Array.isArray(object.attachment) && object.attachment.length)) {
-          return { status: 400, error: 'empty_note' };
-        }
-        // Direct (private mention, shaer-tqc): NOT a post. Delivered over the
-        // outbox machinery to the addressed inboxes only; shows under Messages.
-        if (c2sVisibility(object) === 'direct') {
-          const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string');
-          const recipients = [...new Set([...arr(object.to), ...arr(object.cc)])]
-            .filter((u) => /^https?:\/\//i.test(u) && !/\/followers\/?$/.test(u) && u !== PUBLIC);
-          if (!recipients.length) return { status: 400, error: 'no_recipients' };
-          // AS2 attachments (e.g. the help-buoy capture, uploaded via
-          // uploadMedia): normalize our own absolute /media/ URLs to relative
-          // so the deliverReply-style validation applies unchanged.
-          const atts = (Array.isArray(object.attachment) ? object.attachment : [])
-            .map((a) => a && typeof a === 'object' ? {
-              url: String(a.url || '').replace(new RegExp('^' + base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), ''),
-              mediaType: String(a.mediaType || ''),
-              name: String(a.name || '').slice(0, 120),
-            } : null)
-            .filter(Boolean);
-          const help = object['shaer:helpRequest'] === true || object.helpRequest === true;
-          // FEP-633c 3.6.1: a guardian here declaring itself away to its
-          // wards. An away without a (future) end fails loudly, exactly as
-          // the daemon refuses it: stored quietly it would be a nominal
-          // guardian holding a seat.
-          let awayUntil = null;
-          if (Guardianship.availability.isAway(object)) {
-            awayUntil = Guardianship.availability.parseEndTime(object.endTime);
-            if (!awayUntil || awayUntil <= Date.now()) return { status: 400, error: 'away_needs_an_end' };
-            // No local shortcut here: the note below reaches a ward on this
-            // instance through the loopback, and its inbox handler applies the
-            // absence like it does for a ward anywhere else. One path.
-          }
-          const gateReq = Guardianship.gatereq.parseRequest(object);
-          // Een hulpvraag oppikken of afsluiten vanuit de app (5.2.1, shaer-lgo).
-          // De markering IS al een gewone directe note met een shaer:-eigenschap,
-          // dus hier hoeft niets nieuws bij: de app stuurt precies wat de PWA
-          // stuurt, en het gaat over dezelfde bezorging naar de mede-guardians.
-          //
-          // We boeken hem ook LOKAAL. Zonder dat zou de guardian die de knop
-          // indrukt zijn eigen markering pas zien als hij bij zichzelf
-          // terugkomt -- en die weg bestaat niet.
-          const mark = Guardianship.help.parseMarker(object);
-          if (mark) {
-            const base2 = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-            // Met de VOLLEDIGE handle. Hier stond `@${site.slug}` -- zonder host,
-            // dus een derde vorm naast de kale URI van de PWA-route en de echte
-            // handle die een binnengekomen markering draagt. Drie spellingen van
-            // dezelfde naam, en "door wie" was de hele vraag van shaer-lgo.
-            const mij = actorId(base2, site.slug);
-            Guardianship.help.record(mark.noteUri, mij, mark.kind, deriveHandle(mij));
-          }
-          const r = await deliverDirectNote(site, { recipients, text: plain, language: object.language || null, inReplyTo: typeof object.inReplyTo === 'string' ? object.inReplyTo : null, attachments: atts, helpRequest: help, awayUntil, gateRequest: gateReq && gateReq.feature, helpMark: mark });
-          if (!r || !r.id) return { status: 502, error: 'direct_failed' };
-          return { status: 201, id: r.id, url: `${base}/ap/notes/${r.id}` };
-        }
-        if (object.inReplyTo) {
-          const parent = await resolveRemoteNote(c2sIdOf(object.inReplyTo), { asSlug: site.slug }).catch(() => null);
-          if (!parent) return { status: 502, error: 'cannot_resolve_inReplyTo' };
-          // The attachments ride along (Robins melding, 30-7: "502
-          // reply_failed" op een reply met een foto): deliverReply validates
-          // them itself (own /media only, image|audio|video, max 4) and a
-          // media-only reply is a valid reply there. Dropping them here made
-          // a photo reply arrive naked, and a photo-ONLY reply fail outright.
-          const atts = (Array.isArray(object.attachment) ? object.attachment : [])
-            .map((a) => a && typeof a === 'object' ? {
-              url: String(a.url || '').replace(new RegExp('^' + base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), ''),
-              mediaType: String(a.mediaType || ''),
-              name: String(a.name || '').slice(0, 120),
-            } : null)
-            .filter(Boolean);
-          // DE MENTIONS VAN DE CLIENT (Robins melding, 26-8). Zonder deze
-          // regel kreeg deliverReply `mentions: undefined`, en dat betekent
-          // daar "oud gedrag: noem alleen de auteur van de ouder". Een client
-          // die er drie stuurde zag er dus een gepubliceerd worden -- niet
-          // door een filter, maar doordat de andere twee hier nooit aankwamen.
-          //
-          // De tags zijn de bron, niet `to`/`cc`: die dragen ook de
-          // volgerscollectie en Public, en dat zijn geen mensen. `href` is de
-          // actor, `name` de handle zoals de client hem spelt.
-          //
-          // Ontdubbeld op actor, want de ouder-auteur zit meestal ook in de
-          // tags en zou anders twee keer vooraan komen te staan.
-          //
-          // GEEN tags meegestuurd blijft undefined en dus het oude gedrag. Een
-          // LEGE lijst kan niet: dat betekent in deliverReply "niemand noemen",
-          // en dat is een keuze die een client die geen tags kent nooit maakte.
-          const gezien = new Set();
-          const mentions = (Array.isArray(object.tag) ? object.tag : (object.tag ? [object.tag] : []))
-            .filter((t) => t && t.type === 'Mention' && typeof t.href === 'string' && /^https?:\/\//i.test(t.href))
-            .filter((t) => !gezien.has(t.href) && gezien.add(t.href))
-            .map((t) => ({ uri: t.href, url: t.href, handle: typeof t.name === 'string' ? t.name : undefined }));
-          // Honour the client's visibility for the reply: 'friends' (followers-
-          // only, the Shaer detail-view Reply) drops Public; anything else stays
-          // quiet-public. 'direct' was already handled above.
-          const r = await deliverReply(site, {
-            postId: parent.localPostId || '', postSlug: null, parent, text: plain,
-            html: object.content || null, attachments: atts,
-            language: object.language || null, visibility: c2sVisibility(object),
-            mentions: mentions.length ? mentions : undefined,
-          });
-          if (!r || !r.id) return { status: 502, error: 'reply_failed' };
-          return { status: 201, id: r.id, url: `${base}/ap/notes/${r.id}` };
-        }
-        return await c2sCreatePost(base, site, user, object);
-      }
-      // ── Gelezen tot hier (shaer-frontend-3tx) ───────────────────
-      //
-      // AS2 kent Read: 'the actor has read the object'. Geen shaer:seen
-      // verzinnen, en geen zetbare stand: dit is een GEBEURTENIS, dus twee
-      // toestellen kunnen elkaar niet terugzetten. Blijft lokaal -- een
-      // leesbevestiging heeft in de fediverse niets te zoeken.
-      case 'Read': {
-        const targetUri = c2sIdOf(object);
-        if (!targetUri) return { status: 400, error: 'missing_object' };
-        const uit = markRead(site.slug, targetUri);
-        // Kennen we die note niet, dan is er niets gelezen om te onthouden.
-        // Geen fout: een client mag best een oud bericht aanwijzen.
-        return { status: uit ? 200 : 202 };
-      }
-      case 'Like':
-      case 'Announce': {
-        const targetUri = c2sIdOf(object);
-        if (!targetUri) return { status: 400, error: 'missing_object' };
-        // A non-public local note cannot be boosted or liked into the open
-        // (shaer-tqc hardening; the Mastodon 422 equivalent).
-        const localPid = postIdFromNoteUrl(targetUri, base);
-        if (localPid) {
-          const p = db.prepare('SELECT fan_only, ap_visibility FROM posts WHERE id = ?').get(localPid);
-          if (p && (p.fan_only || p.ap_visibility === 'direct' || p.ap_visibility === 'friends')) {
-            return { status: 403, error: 'not_public' };
-          }
-        }
-        const note = await resolveRemoteNote(targetUri, { asSlug: site.slug }).catch(() => null);
-        const objUri = (note && note.object_uri) || targetUri;
-        const authorUri = note && note.actor_uri;
-        const kind = type === 'Announce' ? 'boost' : 'like';
-        await sendInteraction(site, kind, objUri, authorUri);
-        // Eén schrijfpad (shaer-9e9): tussentabel + afgeleide vlag in één keer.
-        // De note gaat mee zodat een boost de post je tijdlijn in trekt.
-        try { setReaction(site.slug, targetUri, kind, true, { flagUri: objUri, note: type === 'Announce' ? note : null }); }
-        catch { /* non-fatal: een reactie mag nooit de bezorging blokkeren */ }
-        // Een Like uit een app moet ook in ap_timeline.liked landen, want dat
-        // is wat de C2S-tijdlijn als shaer:liked teruggeeft. Zonder dit werd
-        // de reactie wel opgeslagen (setMyReaction, de webroute leest die),
-        // maar kreeg de app altijd liked:false terug: het hartje sprong bij de
-        // eerste herlaadbeurt uit, en un-liken kon niet meer -- de app bood
-        // alleen nog "Like" aan en stuurde bij elke tik een nieuwe Like.
-        // Anders dan bij een boost geen upsert: een like hoort een post niet
-        // in je tijdlijn te trekken, dus staat de post er niet in, dan is dit
-        // terecht een no-op.
-        return { status: 202, url: objUri };
-      }
-      case 'Follow': {
-        const actorUri = c2sIdOf(object);
-        if (!actorUri) return { status: 400, error: 'missing_object' };
-        // FEP-633c §5.3 outbound (shaer-p729): a ward asks its guardians first.
-        // A held request is a THIRD outcome — not sent, not failed — and it
-        // travels to the app as one, so Shaer can show "waiting" instead of a
-        // tile that already looks followed.
-        const held = await gateOutgoingFollow(site, actorUri);
-        if (held) {
-          return {
-            status: 202, url: actorUri, id: held.id,
-            state: held.status === 'denied' ? 'refused_by_guardian' : 'awaiting_guardian',
-          };
-        }
-        // The error REACHES the app (Robins melding, 31-7): swallowing it
-        // made a failed follow look exactly like a successful one.
-        const r = await followActor(site, actorUri);
-        if (r && r.error) return { status: 502, error: 'follow_failed', detail: r.error };
-        return { status: 202, url: actorUri };
-      }
-      // Shaer "in Orbit" = a real Block (FEP-c648 client side): lands in
-      // ap_blocks, shows in the Block tab, and purges the actor's cached
-      // content. Client-side filtering becomes a cache of this state.
-      case 'Block': {
-        const targetUri = c2sIdOf(object);
-        if (!targetUri) return { status: 400, error: 'missing_object' };
-        const r = await blockTarget(site, targetUri);
-        if (r && r.error) return { status: 400, error: r.error };
-        return { status: 202, url: targetUri };
-      }
-      case 'Undo': {
-        const inner = object && typeof object === 'object' ? object : null;
-        let innerType = inner && inner.type;
-        if (Array.isArray(innerType)) innerType = innerType.find((t) => typeof t === 'string');
-        const innerTarget = c2sIdOf(inner && inner.object);
-        if (innerType === 'Follow') { await unfollowActor(site, innerTarget); return { status: 202, url: innerTarget }; }
-        if (innerType === 'Block') {
-          if (!innerTarget) return { status: 400, error: 'missing_object' };
-          unblock(site, innerTarget).catch(() => {});   // release from Orbit
-          return { status: 202, url: innerTarget };
-        }
-        if (innerType === 'Like' || innerType === 'Announce') {
-          const kind = innerType === 'Announce' ? 'unboost' : 'unlike';
-          const note = await resolveRemoteNote(innerTarget, { asSlug: site.slug }).catch(() => null);
-          const objUri = (note && note.object_uri) || innerTarget;
-          await sendInteraction(site, kind, objUri, note && note.actor_uri);
-          try { setReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false, { flagUri: objUri }); }
-          catch { /* non-fatal */ }
-          return { status: 202, url: objUri };
-        }
-        return { status: 400, error: 'unsupported_undo' };
-      }
-      // Delete your OWN note (Robins verzoek, 30-7: long-press delete in de
-      // app). Scope stays narrow: this account's posts and outbound replies,
-      // nothing else. The web delete route is the model: Tombstone to the
-      // followers first, then the cascade, so nobody keeps a live copy of a
-      // post the child took back.
-      case 'Delete': {
-        const targetUri = c2sIdOf(object);
-        if (!targetUri) return { status: 400, error: 'missing_object' };
-        const pid = postIdFromNoteUrl(targetUri, base);
-        if (pid) {
-          const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(pid);
-          if (post) {
-            if (post.site_id !== site.id) return { status: 403, error: 'not_your_note' };
-            if (post.status === 'published') deliverDelete(site, post).catch(() => { /* best-effort */ });
-            db.transaction(() => {
-              db.prepare('DELETE FROM comments WHERE post_id = ?').run(post.id);
-              try { db.prepare('DELETE FROM posts_fts WHERE post_id = ?').run(post.id); } catch { /* FTS optional */ }
-              db.prepare('DELETE FROM posts WHERE id = ?').run(post.id);
-            })();
-            return { status: 202, url: targetUri };
-          }
-          // Same /ap/notes/ namespace: one of our outbound replies/messages.
-          // deliverOutboxDelete checks the site itself and tombstones too.
-          if (await deliverOutboxDelete(site, pid)) return { status: 202, url: targetUri };
-        }
-        return { status: 404, error: 'not_your_note' };
-      }
-      // Update of arbitrary objects needs the post-edit pipeline; tracked
-      // separately (klonkt-demo-c2s-del). Reject clearly rather than half-doing it.
-      default:
-        return { status: 400, error: 'unsupported_type', detail: String(type || 'none') };
-    }
-  } catch (e) {
-    console.warn('[AP] C2S ingest failed:', e && e.message);
-    return { status: 500, error: 'ingest_error' };
-  }
-}
-
-// Create a top-level microblog post from a C2S Note and federate it. Minimal
-// sibling of the /posts/create route: sanitized HTML content, no title/cover.
-async function c2sCreatePost(base, site, user, object) {
-  const html = HtmlSanitizerService.sanitize(object.content || (object.source && object.source.content) || '');
-  // Media on a top-level post (shaer-j3uh/-oqxk/-df3i): same rules as
-  // deliverReply — only our OWN uploads, image/audio/video, max 4. They used
-  // to be silently dropped here, so a photo post from the app arrived naked.
-  const media = (Array.isArray(object.attachment) ? object.attachment : [])
-    .filter((a) => a && typeof a.url === 'string' && /^\/media\/[\w./-]+$/.test(a.url)
-      && /^(image|audio|video)\//.test(String(a.mediaType || '')))
-    .slice(0, 4)
-    .map((a) => {
-      const entry = { url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) };
-      // The poster the upload leg made, when it did: a video's still frame
-      // (shaer-zowq, .poster.jpg) or an audio's waveform (Robins vraag 30-7,
-      // .poster.png). Rides along so the tag, the federated attachment and
-      // the apps all have something to show instead of a bare box.
-      const posterExt = entry.mediaType.startsWith('video/') ? '.poster.jpg'
-        : entry.mediaType.startsWith('audio/') ? '.poster.png' : null;
-      if (posterExt) {
-        try {
-          const mediaRoot = path.resolve(process.env.MEDIA_PATH || './storage/media');
-          const rel = entry.url.replace(/^\/media\//, '');
-          if (fs.existsSync(path.join(mediaRoot, rel + posterExt))) entry.poster = entry.url + posterExt;
-        } catch { /* no poster is fine */ }
-      }
-      return entry;
-    });
-  if (!html.trim() && !media.length) return { status: 400, error: 'empty_note' };
-  // The web reads the post's content, so the media goes IN it (we build these
-  // tags ourselves from validated paths, after the sanitizer). buildNote
-  // strips <img> back out into AS2 attachments; audio/video tags stay for the
-  // web player and federate via c2s_attachments below.
-  const esc = (t) => String(t).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
-  const mediaHtml = media.map((a) => {
-    if (a.mediaType.startsWith('image/')) return `<p><img src="${a.url}" alt="${esc(a.name)}"></p>`;
-    // data-poster: <audio> has no poster attribute, but the tile derivation
-    // reads this one to show the waveform (post-tile/post-card).
-    if (a.mediaType.startsWith('audio/')) return `<p><audio controls preload="metadata"${a.poster ? ` data-poster="${a.poster}"` : ''} src="${a.url}"></audio></p>`;
-    const poster = a.poster ? ` poster="${a.poster}"` : '';
-    return `<p><video controls playsinline preload="metadata"${poster} src="${a.url}"></video></p>`;
-  }).join('');
-  // De titel (shaer-uply): AS2 zet hem in `name`, en die werd hier nooit
-  // gelezen -- een client kon hem zetten en hij verdween geruisloos, het
-  // slechtste van de drie mogelijke gedragingen. Platte tekst, want dat is wat
-  // `name` per AS2 is en wat de titelkolom overal verwacht; wie er toch HTML
-  // in stopt houdt de tekst over. De grens van 200 is de huisregel voor korte
-  // vrije tekst hier (content warning, sitetitel) -- de posteditor op het web
-  // heeft geen eigen grens, dus strenger dan het web zijn we hiermee niet
-  // op een manier die iemand merkt.
-  // Vanaf de kolom doet de bestaande machinerie de rest: het web toont hem,
-  // en buildNote vouwt hem als vetgedrukte eerste regel in de content
-  // (Mastodon negeert `name` op een Note).
-  const title = HtmlSanitizerService.toPlainText(typeof object.name === 'string' ? object.name : '').trim().slice(0, 200);
-  const postId = crypto.randomUUID();
-  const slug = 'n-' + postId.slice(0, 8);
-  const now = new Date().toISOString();
-  // Visibility from the note's addressing (shaer-60b): Public in `to` = loud
-  // public, Public in `cc` = quiet public (unlisted), followers-only = friends
-  // (rides the existing fan_only pipeline: followers-only AP delivery + web
-  // gating), neither = participants-only (kept local until mention addressing
-  // lands; still followers-gated on the web).
-  const vis = c2sVisibility(object);
-  const fanOnly = (vis === 'friends' || vis === 'direct') ? 1 : 0;
-  // Deliberately NO cover (Robins besluit, 30-7): the media lives in the
-  // content, and a cover next to it showed the same video twice on the post
-  // page. The tiles derive their picture from the content instead.
-  db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, excerpt, status, type, language, fan_only, ap_visibility, created_at, updated_at, published_at)
-              VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
-    .run(postId, site.id, slug, user.id, title, html + mediaHtml, '', 'published', 'post', object.language || 'nl', fanOnly, vis, now, now, now);
-  if (media.length) { try { db.prepare('UPDATE posts SET c2s_attachments = ? WHERE id = ?').run(JSON.stringify(media), postId); } catch { /* column exists via ensureColumn */ } }
-  try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(bakePostContent(html + mediaHtml), postId); } catch { /* render fallback covers it */ }
-  bakePostContentWithMentions(html + mediaHtml).then((h) => { try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(h, postId); } catch { /* keep sync bake */ } }).catch(() => {});
-  // Ook in de zoekindex, en niet alleen in de kolom (shaer-uply): anders is
-  // een getitelde C2S-post wel te zien maar niet op zijn titel te vinden.
-  try { db.prepare('INSERT INTO posts_fts(content, title, author, post_id) VALUES (?,?,?,?)').run(HtmlSanitizerService.toPlainText(html), title, user.username || '', postId); } catch { /* FTS non-fatal */ }
-  if (vis !== 'direct') {
-    deliverCreate(site, { id: postId, slug, title, content: html + mediaHtml, published_at: now, created_at: now, fan_only: fanOnly, ap_visibility: vis, c2s_attachments: media.length ? JSON.stringify(media) : null }).catch(() => { /* best-effort */ });
-  }
-  return { status: 201, id: postId, url: `${base}/ap/notes/${postId}` };
-}
Index: src/services/ap-cirkel.js
===================================================================
--- src/services/ap-cirkel.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,38 +1,0 @@
-/**
- * ap-cirkel.js — de Cirkel (stap 10 van shaer-drc).
- *
- * De feed van uitgelichte accounts (auto_boost) plus zelf gebooste posts,
- * en de twee lijstjes eromheen. Leest ap_timeline, ap_following en
- * ap_my_reactions; schrijft niets. De enige snede tot nu toe zonder ook maar
- * een werktuig uit de dienstlaag: alleen db.
- */
-import db, { isoSql } from '../config/database.js';
-
-// ── Cirkel = posts from the accounts you auto-boost ("feature an artist") ──
-let _abCount, _cirkelPosts, _cirkelMembers;
-export function autoBoostCount(slug) {
-  try { if (!_abCount) _abCount = db.prepare('SELECT COUNT(*) AS n FROM ap_following WHERE slug = ? AND auto_boost = 1'); return _abCount.get(slug).n; } catch { return 0; }
-}
-export function getCirkelPosts(slug, limit, offset) {
-  try {
-    // Cirkel = posts from featured (auto_boost) accounts + posts you boosted
-    // (t.boosted), mixed by date. One row per note in ap_timeline → no duplicates.
-    if (!_cirkelPosts) _cirkelPosts = db.prepare(`
-      SELECT t.id, t.author_uri, t.author_name, t.author_handle, t.author_icon, t.author_url,
-             t.content, t.url, t.published, t.media_json, t.nsfw, t.cw,
-             (rb.target_uri IS NOT NULL) AS boosted
-      FROM ap_timeline t
-      LEFT JOIN ap_following f ON f.slug = t.slug AND f.actor_uri = t.author_uri
-      -- Uit de tussentabel, niet uit t.boosted: die kolom is een afgeleide. De
-      -- UNIQUE(site_slug, target_uri, kind) garandeert hoogstens één match, dus
-      -- deze join kan geen rijen verdubbelen.
-      LEFT JOIN ap_my_reactions rb ON rb.site_slug = t.slug AND rb.target_uri = t.id AND rb.kind = 'boost'
-      WHERE t.slug = ? AND (f.auto_boost = 1 OR rb.target_uri IS NOT NULL)
-      ORDER BY ${isoSql('COALESCE(t.published, t.created_at)')} DESC, t.rowid DESC
-      LIMIT ? OFFSET ?`);
-    return _cirkelPosts.all(slug, limit || 60, offset || 0);
-  } catch { return []; }
-}
-export function getCirkelMembers(slug) {
-  try { if (!_cirkelMembers) _cirkelMembers = db.prepare('SELECT name, url, icon FROM ap_following WHERE slug = ? AND auto_boost = 1 ORDER BY name'); return _cirkelMembers.all(slug); } catch { return []; }
-}
Index: src/services/ap-core.js
===================================================================
--- src/services/ap-core.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,360 +1,0 @@
-/**
- * De primitieven die iedereen die ActivityPub uitzendt nodig heeft.
- *
- * Waarom dit bestand er is (shaer-drc): ActivityPubService.js was 6436 regels
- * met 166 exports en tweeentwintig secties. Een submap zoals music/ kan pas
- * zelfstandig bestaan als deze zes dingen ergens staan waar BEIDE uit kunnen
- * putten -- anders importeert de submap uit ActivityPubService en importeert
- * die weer terug, en dat is een kring.
- *
- * Guardianship laat zien hoe het wel moet: die map importeert alleen db en zijn
- * eigen buren, nooit terug. Dit bestand maakt datzelfde mogelijk voor de rest.
- *
- * Alles hier is PUUR: geen database, geen netwerk, geen toestand. Dat is de
- * grens -- komt daar iets bij dat wel iets weet, dan hoort het hier niet.
- *
- * EEN UITZONDERING OP "PUUR": AP_CONTEXT stelt zichzelf samen uit
- * Guardianship.SHAER_CONTEXT. Die context is nu eenmaal de optelsom van ieders
- * termen, dus dat hoort zo. Het maakt geen kring: guardianship kent alleen db
- * en zijn eigen buren en importeert nooit terug.
- */
-
-import * as Guardianship from './guardianship/index.js';
-
-export const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
-// Full JSON-LD context for every AP object we emit: AS2 core + security (publicKey) + the
-// extension terms we actually use (Mastodon/toot + schema.org), each with a term definition
-// so a strict JSON-LD processor resolves them instead of dropping them → valid AS2/JSON-LD.
-// This is the same context shape Mastodon publishes, so Mastodon sees no change.
-export const AP_CONTEXT = [
-  'https://www.w3.org/ns/activitystreams',
-  'https://w3id.org/security/v1',
-  {
-    toot: 'http://joinmastodon.org/ns#',
-    schema: 'http://schema.org#',
-    sensitive: 'as:sensitive',
-    Hashtag: 'as:Hashtag',
-    manuallyApprovesFollowers: 'as:manuallyApprovesFollowers',
-    discoverable: 'toot:discoverable',
-    // FEP-7628 (account moves): same term declaration Mastodon ships.
-    alsoKnownAs: { '@id': 'as:alsoKnownAs', '@type': '@id' },
-    movedTo: { '@id': 'as:movedTo', '@type': '@id' },
-    featured: { '@id': 'toot:featured', '@type': '@id' },
-    PropertyValue: 'schema:PropertyValue',
-    value: 'schema:value',
-    embedUrl: { '@id': 'schema:embedUrl', '@type': '@id' },
-    // Wat een track beschrijft en AS2 niet kent (shaer-0nh). Funkwhale zet deze
-    // vier op zijn Audio; het bleken geen eigen verzinsels maar termen die
-    // schema.org gewoon heeft -- en schema.org stond hier al. De SLEUTELS zijn
-    // die van Funkwhale, want daar leest hij op; de BETEKENIS komt van
-    // schema.org, dus we hoeven geen vreemd vocabulaire binnen te halen.
-    license: { '@id': 'schema:license', '@type': '@id' },
-    // "Dit ding is ook bekend onder die URI" -- voor de MusicBrainz-koppeling
-    // van een artiest (shaer-mbz). Bewust NIET alsoKnownAs: dat is in AS2
-    // gereserveerd voor vroegere IDENTITEITEN van dezelfde actor, en een
-    // verhuizing leunt erop (FEP-7628). Een verwijzing naar een register is
-    // iets anders dan een oud account van jezelf, en die twee door elkaar halen
-    // zou een verhuizing kunnen laten mislukken.
-    sameAs: { '@id': 'schema:sameAs', '@type': '@id' },
-    // ── Wat we uit Funkwhale's vocabulaire overnemen, en waarom ──
-    //
-    // LIBRARY. Gemeten op 13-8: open.audio had onze vier tracks binnengehaald
-    // via hun AP-id, met een artist_credit dat het zelf uit onze attributedTo
-    // afleidde -- maar uploads LEEG en is_playable false. Bij Funkwhale hangt
-    // een upload aan een library; zonder die bak blijft een track een naam
-    // zonder geluid.
-    //
-    // TRACK. Hier stond dat we deze NIET namen, met als reden: hij vraagt een
-    // entiteit waar wij tekst hebben, en er een id voor verzinnen belooft wat
-    // we niet waarmaken. Die redenering klopte half, en de Emissary-meting van
-    // 16-8 (shaer-3f8a) laat zien welke helft. Een TRACK heeft bij ons wel
-    // degelijk een eigen identiteit -- het is een rij met een titel en een
-    // plaats in een uitgave. Wat wij niet hebben is een ALBUM als entiteit, en
-    // dat is een andere vraag (shaer-k37k). Emissary stuurt precies die kleine
-    // vorm: type, id, name, position. Wij ook, en album blijft eruit tot het
-    // een echt object is -- een verzonnen album-URI is nu juist wel de belofte
-    // die we niet kunnen waarmaken.
-    //
-    // Twee onafhankelijke implementaties zenden dit nu, en het is de kant waar
-    // FEP-be68 heen beweegt. ArtistCredit blijft eruit: dat is nog steeds een
-    // entiteit die wij niet hebben.
-    //
-    // De vorm van de termen is letterlijk die van hun contexts.py (regel
-    // 293-306), zodat een lezer die hun context laadt en een lezer die de onze
-    // leest op dezelfde IRI's uitkomen.
-    fw: 'https://funkwhale.audio/ns#',
-    Library: 'fw:Library',
-    library: { '@id': 'fw:library', '@type': '@id' },
-    Track: 'fw:Track',
-    track: { '@id': 'fw:track', '@type': '@id' },
-    // ARTIEST-CREDIT. Hun TrackSerializer eist minstens een artist_credit, en
-    // dat leek lang onmogelijk: het vraagt een Artist met een eigen id, en bij
-    // ons was een artiest tekst. Sinds de MusicBrainz-koppeling (shaer-mbz) is
-    // dat niet meer waar -- de site-ACTOR is de artiest. Een echte, opvraagbare
-    // URI, met de sitetitel als naam en een musicbrainzId als hij gekoppeld is.
-    // Er valt hier niets te verzinnen; open.audio leidde dit zelfs al zelf af
-    // uit onze attributedTo (gemeten 13-8).
-    //
-    // `@container: @list` is GEEN opsmuk. Ze lezen dit veld met
-    // first_attr(FW.artist_credit, "@list"), en zonder die declaratie expandeert
-    // onze array niet naar een @list -- dan staat er iets dat er goed uitziet en
-    // door hun lezer niet gevonden wordt. Letterlijk hun contexts.py regel 311.
-    Artist: 'fw:Artist',
-    ArtistCredit: 'fw:ArtistCredit',
-    artist: { '@id': 'fw:artist', '@type': '@id' },
-    artist_credit: { '@id': 'fw:artist_credit', '@type': '@id', '@container': '@list' },
-    credit: 'fw:credit',
-    musicbrainzId: 'fw:musicbrainzId',
-    position: 'schema:position',
-    bitrate: 'schema:bitrate',
-    size: 'schema:contentSize',
-    // Poll (Question) extension: Question/oneOf/anyOf/endTime/closed are AS2 core, but the
-    // per-poll unique-voter count is a Mastodon (toot) term — declare it so the emitted
-    // Question stays valid JSON-LD (a strict processor would otherwise drop votersCount).
-    votersCount: 'toot:votersCount',
-    // FEP-1580 (objectmigratie bij een Move). FEP-7628 verhuist je VOLGERS en
-    // zegt dat zelf met zoveel woorden: de objecten zijn een ander probleem, en
-    // dit is de FEP waar dat geregeld wordt. De namespace is die van de FEP zelf
-    // (aangemeld via FEP-888d). De CURIE van de collectie is `migration:migration`,
-    // door de auteur zelf "maybe unhelpfully" genoemd; wij emitteren de JSON-sleutel
-    // `migration`, want daar leest een consument op.
-    migration: { '@id': 'https://w3id.org/fep/1580/migration', '@type': '@id' },
-    moves: { '@id': 'https://w3id.org/fep/1580/moves', '@type': '@id' },
-    migrationComplete: 'https://w3id.org/fep/1580/migrationComplete',
-    migratedFrom: { '@id': 'https://w3id.org/fep/1580/migratedFrom', '@type': '@id' },
-    migratedAt: 'https://w3id.org/fep/1580/migratedAt',
-    // Kanaal-vocabulaire (shaer-0nh). Funkwhale declareert `category` niet
-    // inline maar via zijn eigen remote context https://funkwhale.audio/ns, en
-    // die host is vanaf hier onbereikbaar -- de IRI hieronder is dus AFGELEID
-    // en niet geverifieerd. Wat vandaag telt voor interop is de JSON-sleutel,
-    // want daar matchen lezers op; de declaratie zorgt alleen dat een strikte
-    // JSON-LD-processor hem niet laat vallen. Nakijken zodra die host weer
-    // antwoordt.
-    category: { '@id': 'https://funkwhale.audio/ns#category' },
-    // FEP-633c (Guardians): the shaer namespace, owned by the guardianship
-    // module (src/services/guardianship/).
-    ...Guardianship.SHAER_CONTEXT,
-  },
-];
-
-/** Een absolute http(s)-URL, of leeg. De enige plek die bepaalt wat wij een
- *  bruikbare URL vinden. */
-export const safeUrl = (u) => { const s = String(u == null ? '' : u).trim(); return /^https?:\/\//i.test(s) ? s : ''; };
-
-export function actorId(base, slug) { return `${base}/ap/users/${encodeURIComponent(slug)}`; }
-export function noteId(base, postId) { return `${base}/ap/notes/${encodeURIComponent(postId)}`; }
-
-/**
- * mediaType raden uit een bestandsnaam. Stond twee keer functie-lokaal in dit
- * bestand, met een commentaar dat ze "dezelfde afleiding" waren -- en dat was
- * niet zo: de ene kende video, de andere alleen beeld. Nu een kaart, hier.
- * De terugval is image/jpeg omdat dit alleen op omslagen en bijlagen wordt
- * losgelaten, nooit op geluid: dat draagt zijn eigen mime_type uit de database.
- */
-export function guessMediaType(u) {
-  const e = ((u || '').split('?')[0].match(/\.(\w+)$/) || [])[1];
-  return ({
-    jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
-    webp: 'image/webp', avif: 'image/avif',
-    mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
-  })[(e || '').toLowerCase()] || 'image/jpeg';
-}
-
-/**
- * Het tagveld van een post als lijst. Het staat in de database als JSON-ARRAY
- * en niet als kommalijst -- op komma's splitsen levert `#["Doen we Niet"` op,
- * en dat faalt niet, het liegt. Vandaar een echte parser, met de kommavorm als
- * terugval voor wat er handmatig is ingevuld.
- */
-export function normalizeTags(t) {
-  if (Array.isArray(t)) return t;
-  if (typeof t === 'string') {
-    const s = t.trim(); if (!s) return [];
-    if (s[0] === '[') { try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { /* dan toch als kommalijst */ } }
-    return s.split(',').map((x) => x.trim()).filter(Boolean);
-  }
-  return [];
-}
-
-/**
- * Een tag -> { label, slug }. Tags van meerdere woorden worden CamelCase
- * (#LiveMusic) voor de weergavenaam -- een Mastodon-hashtag mag geen spaties
- * bevatten en CamelCase is daar de toegankelijkheidsnorm; de slug en de href
- * blijven kleingeschreven ("livemusic").
- */
-export function tagParts(raw) {
-  const words = String(raw || '').trim().split(/[\s_]+/).map((w) => w.replace(/[^\p{L}\p{M}\p{N}]/gu, '')).filter(Boolean);
-  if (!words.length) return null;
-  const slug = words.join('').toLowerCase();
-  if (!slug) return null;
-  const label = words.length > 1 ? words.map((w) => w[0].toUpperCase() + w.slice(1)).join('') : words[0];
-  return { label, slug };
-}
-
-/**
- * De #hashtags die in het LIJF van een post gelinkt staan, zoals ze GESCHREVEN
- * zijn. De slug in de href is kleingeschreven -- dat is een adres -- maar de
- * naam niet: #DoenweNiet blijft #DoenweNiet.
- */
-export function hashtagTags(base, content) {
-  const tags = [], seen = new Set();
-  const re = /class="[^"]*\bhashtag\b[^"]*"[^>]*>#([\p{L}\p{M}\p{N}_]+)</giu;
-  let m;
-  while ((m = re.exec(content || ''))) {
-    const k = m[1].toLowerCase();
-    if (seen.has(k)) continue; seen.add(k);
-    tags.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(k)}`, name: '#' + m[1] });
-  }
-  return tags;
-}
-
-/**
- * Het tagveld van een post en de #hashtags uit het lijf, samen en ontdubbeld.
- *
- * HET LIJF GAAT VOOR (Robin, 9-8): staat een tag allebei, dan wint de vorm
- * zoals hij GESCHREVEN is. Het tagveld gaat door tagParts, en die maakt van
- * "Doen we Niet" het CamelCase #DoenWeNiet -- nodig, want een hashtag mag geen
- * spaties bevatten. Maar als iemand in zijn tekst #DoenweNiet heeft getypt is
- * dat geen benadering meer maar de tag zelf, en dan hoort die te staan zoals
- * hij er staat. Eerder won het veld, en verdween de geschreven vorm.
- *
- * `opts.ruw` voor inhoud die nog niet door de renderer is geweest: dan staan de
- * hashtags er als kale tekst en niet als <a class="hashtag">. buildNote krijgt
- * het bewerkte lijf en heeft dit niet nodig; wie rechtstreeks uit posts.content
- * leest wel -- anders vindt hij er geen enkele en valt hij stil terug op het
- * tagveld, precies de vorm die hier juist niet moest winnen.
- */
-export function hashtagTagsRuw(base, content) {
-  const tags = [], seen = new Set();
-  // Moet met een LETTER beginnen: "#12" in "issue #12" is een nummer en geen
-  // tag, en die zou hier anders als hashtag de deur uit gaan.
-  const re = /(^|[\s>(\[])#(\p{L}[\p{L}\p{M}\p{N}_]*)/gu;
-  let m;
-  while ((m = re.exec(content || ''))) {
-    const k = m[2].toLowerCase();
-    if (seen.has(k)) continue; seen.add(k);
-    tags.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(k)}`, name: '#' + m[2] });
-  }
-  return tags;
-}
-
-export function buildHashtagList(base, tagsField, content, opts = {}) {
-  const out = [], seen = new Set();
-  const uitLijf = opts.ruw
-    ? [...hashtagTags(base, content), ...hashtagTagsRuw(base, content)]
-    : hashtagTags(base, content);
-  for (const h of uitLijf) {
-    const k = h.name.slice(1).toLowerCase(); if (seen.has(k)) continue; seen.add(k);
-    out.push(h);
-  }
-  for (const t of normalizeTags(tagsField)) {
-    const p = tagParts(t); if (!p || seen.has(p.slug)) continue; seen.add(p.slug);
-    out.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(p.slug)}`, name: '#' + p.label });
-  }
-  return out;
-}
-
-/**
- * Een AS2-collectie MET de paginavelden erbij (shaer-0nh, 11-8).
- *
- * WAAROM DIT EEN HELPER IS EN GEEN REGELS. Funkwhale weigerde onze outbox met
- * "first: This field is required" en "last: This field is required" -- de eerste
- * concrete reden die we hoorden waarom er niets van ons binnenkwam. AS2 EIST die
- * velden niet, maar bijna iedereen pagineert, en een lezer die de paginaweg
- * volgt liep dood. Toen dat voor de outbox gerepareerd was misten alle andere
- * collecties ze nog steeds. Een helper zorgt dat de volgende collectie ze niet
- * opnieuw vergeet.
- *
- * DE ITEMS BLIJVEN INLINE op de wortel. Shaer bouwt zijn feed daaruit, en wie
- * hem vandaag leest hoort er morgen niet voor te hoeven pagineren. Onze
- * collecties zijn gekapt, dus er is precies EEN pagina en wijzen first en last
- * naar dezelfde.
- *
- * @param {string} id        de collectie-uri, zonder query
- * @param {Array}  items     wat erin zit (mag leeg)
- * @param {object} opts
- *   totalItems  als de telling niet items.length is (followers geeft publiek
- *               alleen een AANTAL en houdt de lijst dicht)
- *   page        true -> een OrderedCollectionPage met partOf in plaats van de wortel
- *   extra       velden die op de wortel horen (attributedTo, shaer:*)
- */
-/** Hoeveel items op een pagina. Gelijk aan wat de outbox vroeger als KAP had. */
-export const PAGINA_GROOTTE = 20;
-
-/**
- * Een collectie, met ECHTE paginering (shaer-sk4).
- *
- * Wat hier stond was een omhulsel: `page` veranderde alleen de VORM en er werd
- * nooit gesneden. `first` en `last` wezen allebei naar ?page=1, elke ?page=N gaf
- * dezelfde items, en pagina 99 noemde zichzelf pagina 1. Robin zag dat de
- * pagina's identiek bleven; dit is waarom.
- *
- * DE WORTEL BLIJFT ZIJN ITEMS INLINE DRAGEN, en dat is geen slordigheid maar de
- * hele reden dat dit veilig is. Shaer leest één document en volgt `next` niet;
- * zou de wortel nu leeg worden, dan kreeg elke draaiende app nul items en geen
- * foutmelding. Eerst de clients leren pagineren, dan pas de wortel afslanken.
- *
- * Een pagina VOORBIJ het einde is leeg en zegt dat ook -- met zijn eigen nummer
- * en zonder `next`. Hem naar de laatste pagina terugbuigen zou opnieuw een
- * antwoord zijn dat over zichzelf liegt.
- *
- * `ongeordend` maakt er de NIET-geordende vorm van: `Collection` met
- * `CollectionPage` en `items`, in plaats van `OrderedCollection` met
- * `OrderedCollectionPage` en `orderedItems`. Dat is geen dialect maar de andere
- * helft van AS2 -- en de bibliotheek hoort daar: een platenkast heeft geen
- * volgorde die iets betekent, en `Library` is bij Funkwhale expliciet een
- * `Collection`. Onze outbox is wél geordend (chronologie is daar de inhoud) en
- * blijft dus zoals hij was.
- *
- * De pagina draagt in die vorm ook `first` en `last`. AS2 staat dat toe --
- * CollectionPage erft van Collection -- en een lezer die halverwege binnenkomt
- * kan zo terug naar het begin zonder eerst de wortel op te halen.
- */
-export function pagedCollection(id, items, { totalItems, page = false, perPage = PAGINA_GROOTTE, alGesneden = false, ongeordend = false, extra = {} } = {}) {
-  const lijst = items || [];
-  const telling = totalItems === undefined ? lijst.length : totalItems;
-  const grootte = Math.max(1, Number(perPage) || PAGINA_GROOTTE);
-  // `alGesneden` voor wie in SQL al gepagineerd heeft (de outbox): dan is `lijst`
-  // een PAGINA en zegt hij niets over het geheel, dus telt het aantal pagina's
-  // uit `totalItems`. Zonder dat zou een volle pagina zichzelf als de enige zien
-  // en nooit een `next` aanbieden.
-  const paginas = Math.max(1, Math.ceil((alGesneden ? telling : lijst.length) / grootte));
-  const url = (n) => `${id}?page=${n}`;
-
-  if (page) {
-    const n = Math.max(1, Math.floor(Number(page)) || 1);
-    const deel = alGesneden ? lijst : lijst.slice((n - 1) * grootte, n * grootte);
-    return {
-      '@context': AP_CONTEXT,
-      id: url(n),
-      type: ongeordend ? 'CollectionPage' : 'OrderedCollectionPage',
-      partOf: id,
-      totalItems: telling,
-      ...(ongeordend ? { first: url(1), last: url(paginas) } : {}),
-      ...(extra.attributedTo ? { attributedTo: extra.attributedTo } : {}),
-      ...(n > 1 ? { prev: url(n - 1) } : {}),
-      ...(n < paginas ? { next: url(n + 1) } : {}),
-      ...(ongeordend ? { items: deel } : { orderedItems: deel }),
-    };
-  }
-  return {
-    '@context': AP_CONTEXT,
-    id,
-    type: ongeordend ? 'Collection' : 'OrderedCollection',
-    ...extra,
-    totalItems: telling,
-    first: url(1),
-    last: url(paginas),
-    ...(ongeordend ? { items: lijst } : { orderedItems: lijst }),
-  };
-}
-
-/** Is dit een MBID? Een UUID, en niets anders. */
-export function isMbid(s) {
-  return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(s || '').trim());
-}
-
-/** De publieke pagina van een artiest, of null als het geen MBID is. */
-export function artiestUrl(mbid) {
-  return isMbid(mbid) ? `https://musicbrainz.org/artist/${String(mbid).trim().toLowerCase()}` : null;
-}
Index: src/services/ap-following.js
===================================================================
--- src/services/ap-following.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,201 +1,0 @@
-/**
- * ap-following.js — de volgwinkel (stap 7 van shaer-drc).
- *
- * Alles rond ap_following: WebFinger, de statements, de lijst en de
- * auto-boost-knop, en de drie federatiehandelingen (followActor,
- * resolveRemoteActor, unfollowActor).
- *
- * fwStmts exporteert mee: de Accept-tak van de inbox en de verhuizing
- * (FEP-7628) schrijven de winkel bij en blijven in de dienst wonen -- zelfde
- * verhouding als tlStmts bij ap-timeline. De poortwachter (gateOutgoingFollow)
- * en zijn goedkeuring (performApprovedFollow) blijven daar ook: de eerste komt
- * hier via injectie binnen, de tweede roept followActor gewoon via de dienst
- * aan, en zo is er geen kring.
- */
-import db from '../config/database.js';
-import * as Guardianship from './guardianship/index.js';
-import { safeUrl, actorId, AP_CONTEXT } from './ap-core.js';
-import { safeFetch, signedGetJson, fetchActor, getOrCreateKeys, deliverWithRetry } from './ap-transport.js';
-
-// De werktuigen uit de dienstlaag; ActivityPubService vult ze onderaan.
-let movedRefusal, gateOutgoingFollow, actorInfo, rid, backfillFromOutbox,
-  deliverToActor;
-export function wireFollowing(deps) {
-  ({ movedRefusal, gateOutgoingFollow, actorInfo, rid, backfillFromOutbox,
-    deliverToActor } = deps);
-}
-
-// ── Fediverse CLIENT: follow accounts + home timeline ─────────────
-// Resolve an @user@domain handle to its actor URL via WebFinger.
-export async function webfingerResolve(handle) {
-  const h = String(handle || '').trim().replace(/^@/, '');
-  const parts = h.split('@');
-  if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
-  const acct = `${parts[0]}@${parts[1]}`;
-  try {
-    const r = await safeFetch(`https://${parts[1]}/.well-known/webfinger?resource=acct:${encodeURIComponent(acct)}`,
-      { headers: { Accept: 'application/jrd+json, application/json' } });
-    if (!r.ok) return null;
-    const jrd = await r.json();
-    const link = (jrd.links || []).find((l) => l.rel === 'self' && /activity\+json|ld\+json/.test(l.type || ''));
-    return safeUrl(link ? link.href : '') || null;
-  } catch { return null; }
-}
-
-let _insFw, _delFw, _listFw, _accFw, _accFwByActor, _oneFw, _setAB;
-export function fwStmts() {
-  if (!_insFw) {
-    _insFw = db.prepare('INSERT OR REPLACE INTO ap_following (slug, actor_uri, handle, name, icon, url, inbox, follow_id, status, auto_boost, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
-    _delFw = db.prepare('DELETE FROM ap_following WHERE slug = ? AND actor_uri = ?');
-    _listFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? ORDER BY created_at DESC');
-    _accFw = db.prepare("UPDATE ap_following SET status = 'accepted' WHERE follow_id = ?");
-    // Terugval als de Accept ons follow-id niet teruggeeft (zie de Accept-tak
-    // in handleInbox): dan is het paar dat we WEL zeker weten (deze site, deze
-    // actor) genoeg, mits de rij nog op pending staat.
-    _accFwByActor = db.prepare("UPDATE ap_following SET status = 'accepted' WHERE slug = ? AND actor_uri = ? AND status = 'pending'");
-    _oneFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? AND actor_uri = ?');
-    _setAB = db.prepare('UPDATE ap_following SET auto_boost = ? WHERE slug = ? AND actor_uri = ?');
-  }
-  return { ins: _insFw, del: _delFw, list: _listFw, acc: _accFw, accByActor: _accFwByActor, one: _oneFw, setAB: _setAB };
-}
-export function listFollowing(slug) { return fwStmts().list.all(slug); }
-
-// Toggle auto-boost ("feature") on an account we already follow.
-export function setAutoBoost(slug, actorUri, on) {
-  try { fwStmts().setAB.run(on ? 1 : 0, slug, actorUri); } catch { /* ignore */ }
-  // Featuring an account → AP-native catch-up so the Cirkel isn't empty until they next
-  // post (push doesn't backfill history-before-follow). Fire-and-forget pull, sends nothing.
-  if (on) backfillFromOutbox(slug, actorUri).catch(() => {});
-  return { ok: true };
-}
-
-// Resolve a Klonkt/AP actor URL from a site root: a Klonkt site's root 302s to
-// /ap/users/<slug> (content negotiation; Location may be relative). Used by
-// followActor for bare-domain follows.
-// NB: the old auto-migration of legacy Cirkels (circle_links -> AP follows) was
-// REMOVED on 2026-06-26 — it auto-sent Follows on boot, which violates "the code
-// never throws anything into the fediverse automatically" (would surprise-Follow
-// for some operators at scale). The dead circle_links table stays as harmless dead
-// data; an operator restores an old cirkel by re-following in /following (their click).
-async function resolveApActor(siteUrl) {
-  try {
-    const r = await fetch(siteUrl, { headers: { Accept: 'application/activity+json' }, redirect: 'manual' });
-    if (r.status >= 300 && r.status < 400) { const loc = r.headers.get('location'); if (loc) return new URL(loc, siteUrl).href; }
-    if (r.ok) return siteUrl;
-  } catch { /* unreachable */ }
-  return null;
-}
-
-export async function followActor(site, handle, autoBoost = false, { approved = false } = {}) {
-  const _mv = movedRefusal(site, 'follow'); if (_mv) return _mv;
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !site || !site.slug) return { error: 'config' };
-  // DE POORT STAAT HIER, en niet alleen in de C2S-outbox (shaer-p729, Barts
-  // melding 8-8: de volgverzoeken van Esmee kwamen nooit bij haar guardians
-  // aan). Hij stond in `case 'Follow'` van de outbox -- dus alleen als je via
-  // Shaer volgt. Volgde het kind vanuit Klonkts eigen webinterface, dan werd er
-  // geen verzoek aangemaakt, ging er niets naar de guardians, en was er dus ook
-  // niets om te beantwoorden. Precies dezelfde deur-naast-de-poort als bij de
-  // antwoordpoort vanmiddag (shaer-r4c).
-  //
-  // Merk op wat het NIET was: niet dat een guardian elders het niet kon
-  // beantwoorden. Die weg werkt en levert een Offer af bij de externe guardian.
-  // Er kwam alleen nooit iets aan om af te leveren.
-  //
-  // `approved` is de enige doorlaat, voor performApprovedFollow: zonder dat zou
-  // een goedgekeurd verzoek opnieuw op de poort stuiten en voor eeuwig wachten.
-
-  // Accept any of: a profile/actor URL, an @user@host handle (WebFinger), or a
-  // bare site domain (site.com) — for a single-actor site (Klonkt etc.) the root
-  // resolves to its AP actor, so you can follow a site by just its domain.
-  const s = String(handle || '').trim();
-  let actorUrl;
-  if (/^https?:\/\//i.test(s)) actorUrl = safeUrl(s) || null;
-  else if (s.includes('@')) actorUrl = await webfingerResolve(s);
-  else if (/^[a-z0-9.-]+\.[a-z]{2,}/i.test(s)) actorUrl = await resolveApActor('https://' + s.replace(/^\/+|\/+$/g, ''));
-  else actorUrl = null;
-  if (!actorUrl) return { error: 'not_found' };
-  // NA het oplossen, want een kind volgt net zo goed met @naam@server of een
-  // kaal domein. Zou de poort alleen naar de ruwe invoer kijken, dan is elke
-  // handle een sluiproute -- en dat is precies de fout die we hier repareren,
-  // een maat kleiner.
-  if (!approved) {
-    const held = await gateOutgoingFollow(site, actorUrl);
-    if (held) return { held: true, id: held.id, status: held.status || 'pending' };
-  }
-  // SIGNED, as this actor: an authorized-fetch instance refuses an anonymous
-  // GET of the actor doc, which made following from a boost silently fail
-  // (Robins melding, 31-7). Signed, the other side sees who asks.
-  const actor = await signedGetJson(site.slug, actorUrl);
-  if (!actor || !actor.id || !actor.inbox) return { error: 'unreachable' };
-  const ai = actorInfo(actor, actor.id);
-  const me = actorId(base, site.slug);
-  const keys = getOrCreateKeys(site.slug);
-  const followId = `${me}#follow-${Date.now()}-${rid()}`;
-  fwStmts().ins.run(site.slug, actor.id, ai.handle, ai.name, ai.icon, ai.url, actor.inbox, followId, 'pending', autoBoost ? 1 : 0);
-  const follow = { '@context': AP_CONTEXT, id: followId, type: 'Follow', actor: me, object: actor.id };
-  // Deliver via the retry queue: a Follow that fails the first attempt (peer down,
-  // timeout, transient 5xx) is retried with backoff instead of staying stuck on
-  // 'pending' forever — the Accept can only come back once the Follow lands.
-  await deliverWithRetry(site.slug, actor.inbox, follow, `${me}#main-key`, keys.private_pem);
-  console.log('[AP] follow', site.slug, '→', actor.id);
-  // Follow + feature in one step → backfill their recent posts into the Cirkel right away.
-  if (autoBoost) backfillFromOutbox(site.slug, actor.id).catch(() => {});
-  // A ward's guardians are TOLD about a new follow (Robins verzoek, 31-7):
-  // a follow brings new content into the child's feed, and the village
-  // should know the door opened. A direct note per guardian, best-effort;
-  // FEP-633c 5.3 gates inbound follows, the outbound notice is Shaer policy
-  // for now (bead: spec-vraag).
-  try {
-    const guardians = Guardianship.listGuardians(site.slug);
-    if (guardians.length) {
-      const meRef = actorId(base, site.slug);
-      const esc = (t) => String(t).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
-      const label = esc(ai.name || ai.handle || actor.id);
-      for (const g of guardians) {
-        const note = {
-          id: `${meRef}/follow-notice/${Date.now().toString(36)}${rid()}`,
-          type: 'Note', attributedTo: meRef, to: [g.other_uri],
-          tag: [{ type: 'Mention', href: g.other_uri }],
-          content: `<p>👀 ${esc(site.title || site.slug)} is now following ${label}.</p>`,
-        };
-        deliverToActor(site, g.other_uri, { id: `${note.id}#create`, type: 'Create', actor: meRef, to: [g.other_uri], object: note })
-          .catch(() => { /* retried by the queue */ });
-      }
-      console.log('[AP] follow notice →', guardians.length, 'guardian(s) of', site.slug);
-    }
-  } catch { /* geen guardians is geen fout */ }
-  return { ok: true, name: ai.name, handle: ai.handle, actor: actor.id };
-}
-
-// Resolve a profile URL or @handle to a followable remote actor (for the
-// authorize_interaction "Follow" flow). Returns display fields + inbox, or null
-// when it isn't a reachable actor (e.g. the input was a post, not a profile).
-export async function resolveRemoteActor(input) {
-  const s = String(input || '').trim();
-  const actorUrl = /^https?:\/\//i.test(s) ? (safeUrl(s) || null) : await webfingerResolve(s);
-  if (!actorUrl) return null;
-  const actor = await fetchActor(actorUrl).catch(() => null);
-  if (!actor || !actor.id || !actor.inbox) return null;
-  const ai = actorInfo(actor, actor.id);
-  return { actor_uri: actor.id, actor_name: ai.name, actor_handle: ai.handle, actor_url: ai.url, actor_icon: ai.icon, inbox: actor.inbox };
-}
-
-export async function unfollowActor(site, actorUri) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const me = actorId(base, site.slug);
-  const keys = getOrCreateKeys(site.slug);
-  const row = fwStmts().one.get(site.slug, actorUri);
-  // Undo(Follow) MUST reference the original Follow's real id so the remote can correlate it
-  // and drop the follow. The old `${me}#follow` fallback never matched anything → the unfollow
-  // silently failed on the remote. With no stored follow id (legacy row), skip the network Undo
-  // rather than send an unmatchable one. Deliver durably via the retry queue.
-  if (row && row.inbox && row.follow_id) {
-    const undo = { '@context': AP_CONTEXT, id: `${me}/undo/${Date.now()}-${rid()}`, type: 'Undo', actor: me, object: { id: row.follow_id, type: 'Follow', actor: me, object: actorUri } };
-    deliverWithRetry(site.slug, row.inbox, undo, `${me}#main-key`, keys.private_pem);
-  } else if (row && row.inbox) {
-    console.warn('[AP] unfollow', site.slug, '→', actorUri, '— no stored follow id; removed locally only (legacy follow, remote may keep it)');
-  }
-  fwStmts().del.run(site.slug, actorUri);
-  return { ok: true };
-}
Index: src/services/ap-inbox.js
===================================================================
--- src/services/ap-inbox.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,1056 +1,0 @@
-/**
- * ap-inbox.js — de inbox (stap 9 van shaer-drc).
- *
- * Het hart van de federatie-ontvangst: handleInbox (de grote switch over
- * Follow, Accept, Undo, Create, Like, Announce, Delete, Update, Move, Flag en
- * Block), de her-verificatie van doorgestuurde activiteiten
- * (dereferenceForwarded, shaer-s8k) en de kleine kas eromheen (bekende notes,
- * geziene notes, recente ophaal-missers).
- *
- * De inbox is de SCHAKELKAST van de dienst: hij raakt vrijwel elk cluster.
- * Wat al een eigen module heeft komt statisch binnen (transport, tijdlijn,
- * peilingen, volgwinkel, guardianship, ap-core); de tweeendertig werktuigen
- * die nog in de dienstlaag wonen komen via wireInbox. Die lijst is bewust
- * lang en expliciet -- hij IS de kaart van wat de inbox aanraakt, en elke
- * naam die er ooit afgaat is een cluster dat zelf verhuisd is.
- * De §5.3-goedkeuring (handleFollowApprovalInbox) blijft bij zijn
- * guardian-broers in de dienst, zoals gateOutgoingFollow bij stap 7.
- */
-import db, { NU_ISO } from '../config/database.js';
-import HtmlSanitizerService from './HtmlSanitizerService.js';
-import * as Guardianship from './guardianship/index.js';
-import { t as i18nT } from './i18n.js';
-import { safeUrl, actorId, AP_CONTEXT } from './ap-core.js';
-import {
-  verifyRequest, fetchActor, deliver, deliverWithRetry, signedGetJson,
-  apGetJson, anySigningSlug, getOrCreateKeys,
-} from './ap-transport.js';
-import { tlStmts, extractEmojiTags, extractLinkJson, quoteHrefOf } from './ap-timeline.js';
-import { parsePoll, recordPollBallot } from './ap-polls.js';
-import { fwStmts } from './ap-following.js';
-
-/**
- * Welke objectsoorten deze inbox in de tijdlijn opneemt.
- *
- * `Audio` staat erbij sinds de kanaalbeslissing (shaer-0nh): een Funkwhale-
- * kanaal stuurt Create(Audio), geen Note. Uitbreiden gebeurt HIER en in
- * timelineFields -- en uitdrukkelijk NIET door vreemde soorten tot Note om te
- * vormen. Een Audio is geen Note, en die soort willen we kunnen blijven zien.
- */
-const TIJDLIJN_SOORTEN = new Set(['Note', 'Article', 'Question', 'Audio']);
-
-// De werktuigen uit de dienstlaag; ActivityPubService vult ze onderaan.
-let actorInfo, actorUriOf, backfillFromOutbox, backfillNewFollower,
-  belongsInTimeline, contentWarning, emojiJsonOf, fetchNoteAP,
-  findThreadTarget, fStmts, handleFollowApprovalInbox, handleMoveInbox,
-  isBlockedAny, isRejectedObject, iStmts, libraryOwnerSlug, localMentionSlugs,
-  localPostExists, localSlugOf, mediaFromNote, noteVisibility,
-  postIdFromNoteUrl, pushEvent, pushLang, pushPostCtx, pushPrefix,
-  resolveCard, resolveExternalEmbed, resolveQuote, rid, slugFromActorUrl,
-  storeAuthorEmoji, timelineFields, wakeGuardian;
-export function wireInbox(deps) {
-  ({ actorInfo, actorUriOf, backfillFromOutbox, backfillNewFollower,
-    belongsInTimeline, contentWarning, emojiJsonOf, fetchNoteAP,
-    findThreadTarget, fStmts, handleFollowApprovalInbox, handleMoveInbox,
-    isBlockedAny, isRejectedObject, iStmts, libraryOwnerSlug,
-    localMentionSlugs, localPostExists, localSlugOf, mediaFromNote,
-    noteVisibility, postIdFromNoteUrl, pushEvent, pushLang, pushPostCtx,
-    pushPrefix, resolveCard, resolveExternalEmbed, resolveQuote, rid,
-    slugFromActorUrl, storeAuthorEmoji, timelineFields, wakeGuardian } = deps);
-}
-
-/**
- * Een DOORGESTUURDE activiteit alsnog verifiëren (shaer-s8k).
- *
- * Reageert iemand in een thread, dan stuurt de server van de oorspronkelijke
- * poster die reactie door naar de deelnemers -- en ondertekent met zijn EIGEN
- * sleutel. De handtekening klopt dan, maar de ondertekenaar is niet de auteur,
- * dus de gate hieronder wees hem af. Gevolg: reacties van derden kwamen niet
- * binnen, zonder dat iemand een fout zag.
- *
- * Mastodon lost dit op met een LD-Signature over de payload. Dat vraagt
- * JSON-LD-canonicalisatie; wij doen het lichter en strenger: we geloven de
- * bezorgde inhoud NIET en halen het object op bij de bron.
- *
- * Vier voorwaarden, en geen ervan is optioneel:
- *
- *  1. Alleen Create en Update. Een doorgestuurde Delete is per definitie niet te
- *     dereferencen -- het object is weg -- dus die blijft geweigerd.
- *  2. De host van de object-id MOET die van de geclaimde actor zijn. Zonder dit
- *     anker wijst een doorsturer je naar een host die hij zelf beheert, waar
- *     attributedTo alles kan beweren.
- *  3. Het OPGEHAALDE object wordt gebruikt, niet de bezorgde payload. Anders
- *     levert een doorsturer een echt id met verdraaide inhoud.
- *  4. Mislukt het ophalen, of wijst het object zichzelf niet toe aan de
- *     geclaimde actor, dan blijft het een weigering. Geen twijfelgeval opslaan.
- */
-/** Kennen we deze note? Een eigen post, een eigen outbox-antwoord, een
- *  gecachete post in de tijdlijn, of een reactie die al in een thread van ons
- *  staat. Alle vier zijn een geldige reden dat iemand ons een antwoord daarop
- *  doorstuurt; iets anders is dat niet. */
-function knownNoteUri(uri) {
-  if (!uri || typeof uri !== 'string') return false;
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  try {
-    if (base && uri.startsWith(`${base}/ap/notes/`)) {
-      const seg = decodeURIComponent(uri.slice(`${base}/ap/notes/`.length).split(/[?#]/)[0]);
-      if (db.prepare('SELECT 1 FROM ap_outbox WHERE id = ?').get(seg)) return true;
-      if (db.prepare('SELECT 1 FROM posts WHERE id = ?').get(seg)) return true;
-    }
-    if (db.prepare('SELECT 1 FROM ap_timeline WHERE id = ? LIMIT 1').get(uri)) return true;
-    if (db.prepare('SELECT 1 FROM ap_interactions WHERE object_uri = ? LIMIT 1').get(uri)) return true;
-    // Een antwoord dat we al bezorgd kregen van iemand die we volgen (shaer-e9g).
-    if (db.prepare('SELECT 1 FROM ap_seen_notes WHERE uri = ? LIMIT 1').get(uri)) return true;
-  } catch { /* bij twijfel niet ophalen */ }
-  return false;
-}
-
-/**
- * Onthoud dat we dit bericht al eens bezorgd kregen.
- *
- * Alleen de URI. Geen inhoud, niets op het scherm, geen tweede weergave -- dit
- * beantwoordt uitsluitend de vraag "kennen wij dit bericht?" die knownNoteUri
- * stelt voordat er iets bij de bron wordt opgehaald.
- *
- * De beller bepaalt WIE er onthouden wordt, en dat is de hele veiligheidsvraag:
- * onthouden we zomaar alles wat iemand aflevert, dan kan een vreemde eerst een
- * bericht neerleggen en daarna met een doorgestuurd antwoord dáárop ons naar een
- * adres van zijn keuze sturen. Vandaar dat handleInbox dit alleen doet voor
- * schrijvers die je zelf volgt.
- */
-const SEEN_NOTES_DAYS = 30;
-let _seenSinceSnoei = 0;
-function rememberNoteUri(uri) {
-  if (!uri || typeof uri !== 'string') return;
-  try {
-    db.prepare('INSERT OR IGNORE INTO ap_seen_notes (uri) VALUES (?)').run(uri);
-    // Af en toe opruimen, niet bij het opstarten: een server die weken doorloopt
-    // zou anders nooit snoeien. Doorsturen gebeurt kort na het antwoord, dus wat
-    // ouder is dan een maand beantwoordt geen enkele vraag meer.
-    if (++_seenSinceSnoei >= 500) {
-      _seenSinceSnoei = 0;
-      const r = db.prepare(`DELETE FROM ap_seen_notes WHERE datetime(created_at) < datetime('now', '-${SEEN_NOTES_DAYS} days')`).run();
-      if (r.changes) console.log(`[AP] seen notes: ${r.changes} pruned`);
-    }
-  } catch { /* niet fataal */ }
-}
-const isFollowedActor = (uri) => {
-  try { return !!db.prepare('SELECT 1 FROM ap_following WHERE actor_uri = ? LIMIT 1').get(uri); } catch { return false; }
-};
-
-/**
- * De hulpvraag zoals WIJ hem opsloegen (shaer-gt70).
- *
- * Een markering wijst naar een note-URI, en die komt van de afzender. Welke
- * ward erbij hoort mag daarom niet uit die markering komen maar uit onze eigen
- * rij: de hulpvraag kwam hier binnen als directe vermelding met help_request=1,
- * en `actor_uri` daarvan IS de ward.
- *
- * Geen rij, geen markering. Dat sluit meteen de aardigste variant af: iemand
- * die markeringen stuurt voor hulpvragen die hij ergens anders zag.
- *
- * GEVOLG DAT JE MOET KENNEN: een markering gaat naar [ward, ...guardians] (zie
- * routes/guardian.js), en op de instantie van de WARD staat zijn eigen
- * hulpvraag niet hier maar in ap_outbox. Daar wordt een markering dus
- * geweigerd. Dat kost vandaag niets, en dat is aan BEIDE kanten nagelopen: de
- * enige lezer op de server is het guardian-paneel (queues.helpItemsFor), dat
- * juist deze tabel leest, en de app leest diezelfde queue -- op het account van
- * een kind levert die alleen zijn INKOMENDE hulpvragen op. Een scherm waarop
- * het kind zijn eigen vraag met status ziet bestaat niet: HomeBase filtert de
- * eigen hulpvraag uit de feed en kan hem verder alleen versturen.
- *
- * Komt dat scherm er ooit -- "er is iemand mee bezig", en dat is een goed idee
- * voor een kind dat wacht -- dan is dit de plek: die moet dan ook de eigen
- * uitgaande hulpvraag (ap_outbox) als bron erkennen.
- */
-function helpRequestRow(noteUri) {
-  if (!noteUri || typeof noteUri !== 'string') return null;
-  try {
-    return db.prepare('SELECT slug, actor_uri FROM ap_mentions WHERE object_uri = ? AND help_request = 1 LIMIT 1').get(noteUri) || null;
-  } catch { return null; }
-}
-
-/**
- * Is deze actor guardian van deze ward?
- *
- * De WARD is de bron van waarheid over zijn eigen guardians -- onze tabel kent
- * alleen onze eigen relatie. existingGuardiansOf stelt de vraag op de goede
- * plek: hosten wij de ward, dan is het een databaselezing; woont hij elders,
- * dan komt het uit shaer:guardians op zijn actor.
- *
- * MET EEN CACHE, want dat tweede geval is een netwerkaanroep in het inbox-pad.
- * Zonder zou een vreemde onze inbox kunnen laten wachten door markeringen te
- * blijven sturen. De lokale tak raakt de cache ook, en dat kost daar niets.
- *
- * Vijf minuten is kort genoeg dat een verse guardian niet lang buiten staat, en
- * lang genoeg om herhaald bevragen te dempen. Een geweigerde markering is niet
- * verloren: de andere kant levert opnieuw af, en dan is de cache ververst.
- */
-const _guardiansOfWard = new Map();   // ward-uri -> { at, set }
-const GUARDIAN_CACHE_MS = 5 * 60 * 1000;
-async function isGuardianOfWard(actorUri, wardUri) {
-  if (!actorUri || !wardUri) return false;
-  const nu = Date.now();
-  const gecached = _guardiansOfWard.get(wardUri);
-  if (gecached && nu - gecached.at < GUARDIAN_CACHE_MS) return gecached.set.has(actorUri);
-  let lijst = [];
-  try { lijst = await Guardianship.existingGuardiansOf(wardUri); } catch { lijst = []; }
-  // Een MISLUKTE ophaal niet als lege lijst wegschrijven: dan zou een tijdelijk
-  // onbereikbare server vijf minuten lang elke markering weigeren. Bij twijfel
-  // niets onthouden en de volgende keer opnieuw kijken.
-  if (Array.isArray(lijst) && lijst.length) {
-    if (_guardiansOfWard.size > 500) _guardiansOfWard.clear();   // simpele begrenzing
-    _guardiansOfWard.set(wardUri, { at: nu, set: new Set(lijst) });
-  }
-  return Array.isArray(lijst) && lijst.includes(actorUri);
-}
-
-// Mislukte dereferences kort onthouden. Mastodon herhaalt een bezorging
-// dagenlang; zonder dit doet elke herhaling de fetch opnieuw, ook als die de
-// vorige twintig keer niets opleverde. Dempt meteen de scherpte van misbruik.
-//
-// DE SLEUTEL IS DE HELE BESCHERMING (shaer-qawr). Er zijn twee soorten
-// mislukking en ze zeggen iets heel verschillends:
-//
-//   TRANSPORTFOUT -- de note is niet op te halen. Dat is een eigenschap van de
-//   note zelf en geldt voor iedereen die hem doorstuurt, dus de objId alleen is
-//   de goede sleutel.
-//
-//   attributedTo-MISMATCH -- de bron zegt dat iemand ANDERS de auteur is. Dat
-//   zegt alles over de doorstuurder en niets over de note, dus die onthouden we
-//   per (note, beweerde actor).
-//
-// Met een enkele sleutel voor allebei was dit een censuurknop: neem de echte
-// note-URI van je slachtoffer, zet er je eigen actor op dezelfde host bij en
-// wijs naar een van onze publieke notes. De fetch slaagt, de mismatch volgt, en
-// die note-URI stond dertig minuten op de zwarte lijst -- waarna het ECHTE
-// doorgestuurde antwoord erop stukliep. Elke dertig minuten herhalen gaf
-// onbeperkte, gerichte onderdrukking van een specifiek antwoord, voor een
-// verzoek per keer. Nu raakt de leugenaar alleen zijn eigen ingang.
-//
-// Query en fragment tellen niet mee. Ze horen zelden bij de identiteit van een
-// note, en met een kale URL als sleutel waren ?x=1, ?x=2 enzovoort losse
-// ingangen: dan is de rem geen rem, want varieren kost niets. Zelfde reden dat
-// de host in kleine letters gaat.
-//
-// GEEN rem per HOST, hoe verleidelijk ook: wie een handvol niet-bestaande
-// URL's op een host laat mislukken zou daarmee die HELE host het zwijgen
-// opleggen. Dat is een grotere versie van precies de fout die hier gerepareerd
-// wordt.
-const _derefMiss = new Map();
-const DEREF_MISS_MS = 30 * 60 * 1000;
-const derefKey = (uri, claimedActor) => {
-  let basis = String(uri || '');
-  try { const u = new URL(basis); basis = `${u.protocol}//${u.host.toLowerCase()}${u.pathname}`; }
-  catch { /* onparseerbaar: de kale string is dan de sleutel */ }
-  // Een NUL-teken als scheiding, als escape geschreven en niet als byte: het
-  // kan in geen enkele URL staan, dus een actor-sleutel is nooit per ongeluk
-  // als note-sleutel te lezen.
-  return claimedActor ? `${basis}\u0000${claimedActor}` : basis;
-};
-function derefRecentlyFailed(uri, claimedActor) {
-  for (const k of [derefKey(uri), derefKey(uri, claimedActor)]) {
-    const t = _derefMiss.get(k);
-    if (t === undefined) continue;
-    if (Date.now() - t > DEREF_MISS_MS) { _derefMiss.delete(k); continue; }
-    return true;
-  }
-  return false;
-}
-function noteDerefFailure(uri, claimedActor) {
-  if (_derefMiss.size > 500) {   // simpele begrenzing: oudste helft eruit
-    const oud = [..._derefMiss.entries()].sort((a, b) => a[1] - b[1]).slice(0, 250);
-    for (const [k] of oud) _derefMiss.delete(k);
-  }
-  _derefMiss.set(derefKey(uri, claimedActor), Date.now());
-}
-// Alleen voor de toets: de aanval speelt zich af in deze twee functies, en de
-// weg erheen (dereferenceForwarded) eist https en een echte fetch. De
-// dienstlaag exporteert ze niet, dus het uitvoeroppervlak blijft gelijk.
-export const _derefCacheForTests = { derefRecentlyFailed, noteDerefFailure };
-
-async function dereferenceForwarded(act, claimedActor, type, slugParam) {
-  // Every exit states its reason. Five of the six used to return silently, so a
-  // rejection count could not be told apart from a narrowing that closed too far
-  // — and that is exactly the measurement shaer-drf is waiting for. Bounded by
-  // the signer-mismatch rate (tens per hour), so this is not a noisy log.
-  const skipped = (reason, detail) => {
-    console.log(`[AP] inbox forwarded, skipped (${reason}):`, claimedActor, detail || '');
-    return null;
-  };
-  if (type !== 'Create' && type !== 'Update') return skipped('not Create/Update', type);
-  const o = act && act.object;
-  const objId = typeof o === 'string' ? o : (o && o.id);
-  if (!objId || typeof objId !== 'string' || !/^https:\/\//i.test(objId)) return skipped('no https object id', objId || '(none)');
-  try {
-    if (new URL(objId).host !== new URL(claimedActor).host) return skipped('host anchor', objId);   // ankereis
-  } catch { return skipped('unparsable id', objId); }
-  // Alleen dereferencen als het object beweert een antwoord te zijn op iets van
-  // ONS (shaer-drf). Zonder die eis zijn claimedActor en object.id allebei door
-  // de aanvaller gekozen en eist het host-anker alleen dat ze aan elkaar gelijk
-  // zijn -- dan kan iedereen met een werkende actor ons naar elke URL sturen.
-  // Doorsturen bestaat juist omdát wij in de thread zitten, dus deze eis kost
-  // niets aan legitiem verkeer waarvan we de ouder kennen.
-  const parent = typeof o === 'object' && o
-    ? (typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id))
-    : null;
-  if (!knownNoteUri(parent)) return skipped('unknown inReplyTo', parent || '(none)');
-  if (derefRecentlyFailed(objId, claimedActor)) return skipped('recent failure', objId);
-  // Onbetekend eerst; tekenen alleen als terugval. Anders kan een ander ons een
-  // ONDERTEKEND verzoek naar een adres van zijn keuze laten sturen -- dezelfde
-  // reden als bij fetchActor sinds efe5633.
-  let fetched = await apGetJson(objId).catch(() => null);
-  if (!fetched || fetched.id !== objId) {
-    // The signer used to be slugParam, which is null on the shared inbox — and
-    // that is where forwarded traffic lands, because we advertise a sharedInbox.
-    // signedGetJson falls back to an unsigned GET for a null slug, so a source in
-    // secure mode could never be dereferenced at all. Same fix verifyRequest got
-    // in shaer-afq: any local actor is a valid signer.
-    const asSlug = slugParam || anySigningSlug();
-    if (asSlug) fetched = await signedGetJson(asSlug, objId).catch(() => null);
-  }
-  const attributed = fetched && (typeof fetched.attributedTo === 'string'
-    ? fetched.attributedTo
-    : (fetched.attributedTo && fetched.attributedTo.id));
-  if (!fetched || fetched.id !== objId) {
-    noteDerefFailure(objId);
-    return skipped('fetch failed', objId);
-  }
-  if (attributed !== claimedActor) {
-    // Not a transport hiccup: the source itself says someone else wrote this.
-    // Per (note, beweerde actor), nooit op de note alleen: dit zegt iets over
-    // DEZE doorstuurder, en op de note alleen was het een censuurknop op de
-    // note van een ander (shaer-qawr).
-    noteDerefFailure(objId, claimedActor);
-    return skipped('attributedTo mismatch', `${objId} claims ${attributed || '(none)'}`);
-  }
-  return fetched;
-}
-
-// Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
-export async function handleInbox(req, slugParam, preVerified = null) {
-  const act = req.body || {};
-  const type = act.type;
-  // Real client IP (behind the proxy via `trust proxy`) — logged on dropped/rejected/
-  // ignored inbox hits so an operator can see who is probing their fediverse inbox.
-  const ip = req.ip || (req.connection && req.connection.remoteAddress) || '?';
-  const base = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
-  // preVerified is the loopback (see deliverToActor): a delivery between two
-  // actors on THIS instance never crosses a socket, so there is no signature to
-  // check — but we do know who signed, because we signed it. Handing that in
-  // keeps everything below identical, including the actor-versus-signer check,
-  // which is exactly the check that must not be skipped for being local.
-  const verified = preVerified || await verifyRequest(req, slugParam).catch(() => null);
-
-  // ENFORCE HTTP signatures: a data-affecting activity must be signed by the very
-  // actor it claims to be. No valid signature, or signer ≠ actor → reject (no
-  // forged replies/likes/follows/timeline posts). GET/discovery stays open.
-  const claimedActor = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
-  // Blocked actor/domain → silently drop (202, don't reveal the block).
-  if (claimedActor && isBlockedAny(claimedActor)) { console.log('[AP] inbox dropped (blocked)', claimedActor, 'from', ip); return 202; }
-  const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer', 'Move'];
-  if (GATED.includes(type)) {
-    // Een geldige handtekening van iemand anders dan de auteur is doorsturen,
-    // geen vervalsing. Haal het object dan bij de bron op in plaats van het af
-    // te wijzen; lukt dat niet, dan valt het door naar de weigering hieronder.
-    let forwarded = null;
-    if (verified && claimedActor && verified.id !== claimedActor) {
-      forwarded = await dereferenceForwarded(act, claimedActor, type, slugParam).catch(() => null);
-      if (forwarded) {
-        act.object = forwarded;   // de OPGEHAALDE inhoud, niet de bezorgde
-        console.log('[AP] inbox forwarded, verified at the source:', type, claimedActor, 'via', verified.id);
-      }
-    }
-    if (!forwarded && (!verified || !claimedActor || verified.id !== claimedActor)) {
-      // Drie verschillende oorzaken, die eerder allemaal "unsigned/invalid"
-      // heetten: geen handtekening meegestuurd, wel een handtekening maar niet
-      // te verifiëren (meestal een opgeheven account waarvan de sleutel weg is),
-      // of geldig ondertekend door iemand anders.
-      const reden = verified ? '(signer mismatch)'
-        : (req.headers && req.headers.signature) ? '(signature present, unverifiable)'
-        : '(no signature)';
-      console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, reden);
-      return 401;
-    }
-    // One answer restores everything (FEP-633c 3.6): any VERIFIED activity
-    // from an actor that guards someone here restores it to active for those
-    // wards and cancels any lapse running against it, before the activity is
-    // even looked at. Signature-gated on purpose: an unverified claim of
-    // being gran must not wake gran up.
-    try {
-      const ev = Guardianship.availability.oneAnswer(claimedActor, Date.now());
-      if (ev.restored.length) console.log('[AP] guardian restored (one answer, 3.6):', claimedActor, '→', ev.restored.join(', '));
-      for (const c of ev.cancelledLapses) console.log('[AP] lapse cancelled by an answer from its target:', c.id);
-    } catch { /* availability is never load-bearing for delivery */ }
-  }
-
-  // FEP-633c §5.3 (modelled on the adoption offer): a gated follow forwarded to
-  // the guardians as an Offer(Follow), their Accept/Reject back to the ward.
-  if ((type === 'Offer' || type === 'Accept' || type === 'Reject') && act['shaer:followApproval'] === true) {
-    if (await handleFollowApprovalInbox(act, slugParam)) { console.log('[AP] follow-approval', type, 'from', claimedActor); return 202; }
-  }
-
-  // FEP-633c: the adoption handshake. An Offer lands at the local ward; an
-  // Accept/Reject answers an offer a local guardian sent. Anything the
-  // guardianship module does not recognize falls through to the old paths.
-  // An Undo of the guardianship Relationship (§3.2) is handled here too, and it
-  // must be seen BEFORE the generic Undo branch below, which only knows about
-  // Follow/Like/Announce and would swallow it with a 202.
-  if (type === 'Offer' || type === 'Accept' || type === 'Reject' || (type === 'Undo' && Guardianship.parseUndoRelationship(act))) {
-    // Every LOCAL party this activity is addressed to gets its own copy of the
-    // handshake (a ward and a co-guardian may both live here). Gather candidate
-    // local slugs from the inbox owner, the `to` list, and the ward.
-    // MET localSlugOf en niet met slugFromActorUrl. Dat laatste knipt alleen de
-    // staart van een pad af, zonder naar de HOST te kijken -- en deze uri's
-    // komen uit `to` en uit de relatie, dus van de afzender. Een Offer gericht
-    // aan https://elders.example/ap/users/dev leverde zo de slug "dev" op, en
-    // die bestaat hier. Dan draait onze dev de afhandeling van een activiteit
-    // die nooit aan hem geadresseerd was. localSlugOf eist dat de uri met onze
-    // eigen basis begint en dat de site echt bestaat.
-    const cand = new Set();
-    if (slugParam) cand.add(slugParam);
-    for (const t of (Array.isArray(act.to) ? act.to : (act.to ? [act.to] : []))) {
-      if (typeof t === 'string') { const s = localSlugOf(t); if (s) cand.add(s); }
-    }
-    if (type === 'Offer' || type === 'Undo') {
-      const rel = type === 'Undo' ? Guardianship.parseUndoRelationship(act) : Guardianship.parseRelationship(act.object);
-      if (rel) { const s = localSlugOf(rel.ward); if (s) cand.add(s); }
-    }
-    let consumed = false;
-    for (const slug of cand) {
-      const gsite = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
-      if (gsite && await Guardianship.handleGuardianshipInbox(gsite, act).catch(() => false)) consumed = true;
-    }
-    if (consumed) { console.log('[AP] guardianship', type, 'from', claimedActor); return 202; }
-  }
-
-  // A moderation report (Flag) about our content — store it for the targeted site's owner
-  // (each Klonkt site is moderated by its own owner). Signature is enforced (GATED).
-  if (type === 'Flag') {
-    const objs = Array.isArray(act.object) ? act.object : (act.object ? [act.object] : []);
-    const objectUris = objs.map((o) => (typeof o === 'string' ? o : (o && o.id))).filter(Boolean);
-    let targetSlug = null;
-    const noteIds = [];
-    for (const u of objectUris) {
-      const s = localSlugOf(u);             // one of OURS -- host meegewogen
-      if (s) { targetSlug = targetSlug || s; continue; }
-      const pid = postIdFromNoteUrl(u, base); // one of our notes?
-      if (pid) noteIds.push(pid);
-    }
-    if (!targetSlug && noteIds.length) {
-      try { const r = db.prepare('SELECT s.slug FROM posts p JOIN sites s ON s.id = p.site_id WHERE p.id = ? LIMIT 1').get(noteIds[0]); if (r) targetSlug = r.slug; } catch { /* ignore */ }
-    }
-    if (!targetSlug) return 202; // not about us / can't tell → drop
-    // Flag is GATED, so `verified` is the signer's (reporter's) actor doc already.
-    const ai = actorInfo(verified || null, claimedActor);
-    try {
-      db.prepare('INSERT INTO ap_reports (slug, actor_uri, actor_name, actor_handle, actor_icon, content, objects, created_at) VALUES (?,?,?,?,?,?,?,CURRENT_TIMESTAMP)')
-        .run(targetSlug, claimedActor || null, ai.name, ai.handle, ai.icon, HtmlSanitizerService.toPlainText(act.content || '').slice(0, 3000), JSON.stringify(objectUris.slice(0, 20)));
-      console.log('[AP] report received for', targetSlug, 'from', claimedActor);
-    } catch { /* ignore */ }
-    return 202;
-  }
-
-  // FEP-7628 (DRAFT): an account moved house. Handled before Follow on purpose:
-  // a Move often arrives seconds before the new actor's re-Follow wave, and the
-  // swap below must not race our own outgoing Follow of the target.
-  if (type === 'Move') {
-    return handleMoveInbox(act, { verifiedActor: claimedActor });
-  }
-
-  if (type === 'Follow') {
-    const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
-    // EERST: volgt iemand onze BIBLIOTHEEK in plaats van onze actor? (shaer-0nh)
-    //
-    // Een luisteraar krijgt de muziek en NIET de gewone posts -- wie zich
-    // abonneert op een platenkast heeft niet om de Krant gevraagd. Vandaar een
-    // eigen tabel: zolang ze daar staan kan een postbezorging ze niet per
-    // ongeluk meenemen.
-    //
-    // De bibliotheek is openbaar (alles erin is fedi_open), dus dit accepteert
-    // meteen. Er valt niets goed te keuren, en dan is wachten oneerlijk.
-    const libSlug = libraryOwnerSlug(typeof act.object === 'string' ? act.object : (act.object && act.object.id));
-    if (who && libSlug) {
-      const remote = await fetchActor(who);
-      if (!remote || !remote.inbox) return 202;
-      const fi = actorInfo(remote, who);
-      luisteraars.voegToe(libSlug, {
-        actorUri: who, inbox: remote.inbox,
-        sharedInbox: (remote.endpoints && remote.endpoints.sharedInbox) || null,
-        name: fi.name, handle: fi.handle, icon: fi.icon,
-      });
-      const keys = getOrCreateKeys(libSlug);
-      const accept = {
-        '@context': AP_CONTEXT,
-        id: `${actorId(base, libSlug)}#accept-library-${Date.now()}-${rid()}`,
-        type: 'Accept', actor: actorId(base, libSlug), object: act,
-      };
-      deliver(remote.inbox, accept, `${actorId(base, libSlug)}#main-key`, keys.privatePem)
-        .catch(() => { /* de volger staat er; een mislukte Accept mag dat niet omgooien */ });
-      console.log('[AP] library follow from', who, '->', libSlug);
-      return 202;
-    }
-    // slugParam is de eigenaar van een per-actor inbox; op de GEDEELDE inbox is
-    // die er niet en werd de slug uit act.object geraden. Zonder hostcontrole
-    // kon een Follow op andermans actor met dezelfde padstaart hier een volger
-    // opleveren.
-    const slug = slugParam || localSlugOf(typeof act.object === 'string' ? act.object : (act.object && act.object.id));
-    if (!who || !slug) return 400;
-    const remote = await fetchActor(who);
-    if (!remote || !remote.inbox) return 202; // can't reach them → drop quietly
-    const sharedInbox = (remote.endpoints && remote.endpoints.sharedInbox) || null;
-    const fi = actorInfo(remote, who);   // cache display for the friends list (shaer-aa3)
-    // FEP-633c §5.3: if the followed actor is a WARD (has guardians), the
-    // follow is gated. A committed guardian's own Follow is auto-accepted
-    // (it needs no gate); anyone else is held pending for guardian approval.
-    // Free actors / normal sites have no guardians → fall through, unchanged.
-    const wardGuardians = Guardianship.listGuardians(slug).map((g) => g.other_uri);
-    if (wardGuardians.length && !wardGuardians.includes(who)) {
-      const followId = (typeof act.id === 'string' && act.id) || `${who}#follow-${Date.now()}-${rid()}`;
-      Guardianship.follows.recordPending(slug, {
-        id: followId, follower: who, inbox: remote.inbox, sharedInbox,
-        name: fi.name, handle: fi.handle, icon: fi.icon, activity: act,
-      });
-      // FEP-633c §5.3, modelled on the guardian offer: the ward forwards the
-      // gated follow to its guardians for approval. A LOCAL guardian gets a
-      // push and reads /guardian directly; a REMOTE guardian gets an
-      // Offer(Follow) delivered so its instance stores a copy (same distributed
-      // pattern as the adoption offer). On quorum the ward returns Accept(Follow).
-      const wardActor = actorId(base, slug);
-      const wardKeys = getOrCreateKeys(slug);
-      const followObj = { id: followId, type: 'Follow', actor: who, object: wardActor };
-      // Dormancy evidence (FEP-633c 3.6.2): this decision directly addresses
-      // every guardian. The ONLY admissible evidence is a request like this
-      // one going unanswered; recordRequest itself skips a declared absence.
-      for (const g of wardGuardians) {
-        try { Guardianship.availability.recordRequest(slug, g, followId, Date.now()); } catch { /* never load-bearing */ }
-      }
-      for (const g of wardGuardians) {
-        // Local ONLY when the guardian lives on THIS instance: slugFromActorUrl
-        // ignores the host (an /ap/users/x path on a remote host is someone
-        // else's actor), so also require our base + an existing local site.
-        const gslug = g.startsWith(`${base}/`) ? slugFromActorUrl(g) : null;
-        const isLocal = gslug && db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(gslug);
-        if (isLocal) {
-          const L = pushLang(gslug);
-          // Een volgverzoek is geen mede-voogdij. Deze push leende de tekst van
-          // offer_for_ward en meldde dus een adoptie die niet gebeurde -- met de
-          // volger als onderwerp. Eigen woorden, en allebei de namen erin: wie
-          // er vraagt, en om wie het gaat (shaer-p729).
-          pushEvent(gslug, { type: 'guardian', title: i18nT(L, 'push.n_guard_folin_t'), body: i18nT(L, 'push.n_guard_folin_b', { who: fi.name || fi.handle || i18nT(L, 'notif.someone'), ward: slug }), url: `${pushPrefix(gslug)}/guardian` });
-        } else {
-          fetchActor(g).then((ga) => {
-            const inbox = ga && ((ga.endpoints && ga.endpoints.sharedInbox) || ga.inbox);
-            if (!inbox) return;
-            const beslissend2 = Guardianship.gated.isDecisive(0, Guardianship.follows.followThreshold(guardians.length));
-            const offer = { '@context': AP_CONTEXT, id: `${wardActor}#followoffer-${Date.now()}-${rid()}`, type: 'Offer', actor: wardActor, to: [g], object: followObj, 'shaer:followApproval': true, 'shaer:decisive': beslissend2 };
-            deliverWithRetry(slug, inbox, offer, `${wardActor}#main-key`, wardKeys.private_pem).catch(() => {});
-          }).catch(() => {});
-        }
-      }
-      console.log('[AP] Follow', who, '→ ward', slug, '(gated, awaiting guardians)');
-      return 202;
-    }
-    // De eigenaarspoort (Robins wens, 18-8): met approve_followers aan wordt
-    // een Follow niet automatisch geaccepteerd — hij wacht in dezelfde
-    // wachtrij als een ward-follow, maar hier beslist de EIGENAAR, op
-    // /connect. Zo kan niemand een klonkt zomaar aan een hub of ander
-    // verzamelplatform hangen zonder dat de eigenaar ja heeft gezegd.
-    // Wards vallen hier nooit: de guardianpoort hierboven gaat vóór.
-    const ownerGate = db.prepare('SELECT approve_followers FROM sites WHERE slug = ?').get(slug);
-    if (ownerGate && ownerGate.approve_followers) {
-      const followId = (typeof act.id === 'string' && act.id) || `${who}#follow-${Date.now()}-${rid()}`;
-      Guardianship.follows.recordPending(slug, {
-        id: followId, follower: who, inbox: remote.inbox, sharedInbox,
-        name: fi.name, handle: fi.handle, icon: fi.icon, activity: act, quorum: 'owner',
-      });
-      const L = pushLang(slug);
-      pushEvent(slug, {
-        type: 'follow',
-        title: i18nT(L, 'push.n_folreq_t'),
-        body: i18nT(L, 'push.n_folreq_b', { who: fi.name || fi.handle || i18nT(L, 'notif.someone') }),
-        url: `${pushPrefix(slug)}/connect`,
-      });
-      console.log('[AP] Follow', who, '→', slug, '(awaiting owner approval)');
-      return 202;
-    }
-    fStmts().ins.run(slug, who, remote.inbox, sharedInbox, fi.name, fi.handle, fi.icon);
-    try { _updFDisp.run(fi.name, fi.handle, fi.icon, slug, who); } catch { /* best effort */ }
-    { const L = pushLang(slug); pushEvent(slug, { type: 'follow', title: i18nT(L, 'push.n_follow_t'), body: i18nT(L, 'push.n_follow_b', { who: fi.name || fi.handle || i18nT(L, 'notif.someone') }), url: `${pushPrefix(slug)}/connect` }); }
-    const me = actorId(base, slug);
-    const keys = getOrCreateKeys(slug);
-    const accept = { '@context': AP_CONTEXT, id: `${me}#accept-${Date.now()}-${rid()}`, type: 'Accept', actor: me, object: act };
-    deliver(remote.inbox, accept, `${me}#main-key`, keys.private_pem).catch((e) => console.warn('[AP] Accept delivery failed:', e.message));
-    // Auto-backfill: send our recent posts as Create so the instance has our history
-    // (Mastodon doesn't fetch history on follow). ONCE PER REMOTE INSTANCE only —
-    // Mastodon dedupes notes per-instance, so re-filling an instance that already has
-    // a follower of ours is wasted work (and won't re-populate the new follower's
-    // timeline anyway). Deliver to the shared inbox (instance-level) when present.
-    // Sync insert+check (no await between) → no interleave race with concurrent Follows.
-    const instanceFilled = sharedInbox &&
-      db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND shared_inbox = ? AND actor_uri != ? LIMIT 1')
-        .get(slug, sharedInbox, who);
-    if (!instanceFilled) {
-      backfillNewFollower(base, slug, sharedInbox || remote.inbox).catch(() => { /* best-effort */ });
-    }
-    console.log('[AP] Follow', who, '→', slug, verified ? '(sig ok)' : '(sig unverified)');
-    return 202;
-  }
-  // Een luisteraar die weggaat, hoort meteen weg te zijn.
-  if (type === 'Undo' && act.object && act.object.type === 'Follow') {
-    const doel = typeof act.object.object === 'string' ? act.object.object : (act.object.object && act.object.object.id);
-    const libSlug = libraryOwnerSlug(doel);
-    const wie = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
-    if (libSlug && wie && luisteraars.verwijder(libSlug, wie)) {
-      console.log('[AP] library unfollow from', wie, '->', libSlug);
-      return 202;
-    }
-  }
-
-  if (type === 'Undo' && act.object) {
-    const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
-    const ot = act.object.type;
-    if (ot === 'Follow') {
-      const obj = act.object.object;
-      const slug = slugParam || slugFromActorUrl(typeof obj === 'string' ? obj : (obj && obj.id));
-      if (who && slug) { fStmts().del.run(slug, who); console.log('[AP] Unfollow', who, '→', slug); }
-      return 202;
-    }
-    if (ot === 'Like' || ot === 'Announce') {
-      const tgt = act.object.object;
-      const pid = postIdFromNoteUrl(typeof tgt === 'string' ? tgt : (tgt && tgt.id), base);
-      if (who && pid) { iStmts().delLA.run(ot.toLowerCase(), pid, who); console.log('[AP] Undo', ot, who, '→', pid); }
-      return 202;
-    }
-    return 202;
-  }
-
-  const actorUri = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
-  const resolveActor = async (uri) => ((verified && verified.id === uri) ? verified : await fetchActor(uri).catch(() => null));
-  // Our OWN activity is already stored via ap_outbox: don't store it twice.
-  // "Our own" means THIS inbox's owner, not "anyone who happens to live on this
-  // machine". The old reading dropped every activity between two sites on one
-  // instance, so a note from a co-located guardian to its ward was accepted
-  // with a 202 and then quietly thrown away: no mention, no away, no help
-  // request. Neighbours are not us (Robins regel, 29-7: on this machine
-  // everything behaves as if every Klonkt were somewhere else).
-  const isLocalActor = !!(actorUri && slugParam && actorUri === actorId(base, slugParam));
-
-  // Inbound reply: a Create whose object replies to one of our notes (post OR comment).
-  if (type === 'Create' && act.object && TIJDLIJN_SOORTEN.has(act.object.type)) {
-    const o = act.object;
-    // A poll ballot: a Note carrying a `name` (the chosen option) inReplyTo one of OUR poll
-    // posts. Record it (deduped per actor) BEFORE the reply logic so a vote is never stored
-    // as a comment. recordPollBallot returns handled=false only if the target isn't a poll.
-    if (o.name && o.inReplyTo && actorUri && !isLocalActor) {
-      const seg = postIdFromNoteUrl(o.inReplyTo, base);
-      if (seg && localPostExists(seg)) {
-        const rec = recordPollBallot(seg, actorUri, o.name);
-        if (rec.handled) { console.log('[AP] poll vote', actorUri, '→', seg); return 202; }
-      }
-    }
-    const tgt = findThreadTarget(o.inReplyTo, base);
-    if (tgt && actorUri && !isLocalActor) {
-      const ai = actorInfo(await resolveActor(actorUri), actorUri);
-      const html = HtmlSanitizerService.sanitize(o.content || '');
-      if (isRejectedObject(o.id)) { console.log('[AP] reply skipped (tombstoned)', o.id); return 202; }
-      iStmts().ins.run('reply', tgt.post_id, o.id || '', actorUri, ai.name, ai.handle, ai.url, ai.icon, html, o.published || null, tgt.parent_uri, noteVisibility(o), extractEmojiTags(o.tag), emojiJsonOf(ai.emojis));
-      console.log('[AP] reply', actorUri, '→', tgt.post_id);
-      // A reply is a post too: Berichten renders it the way de Krant renders a
-      // timeline row, so it needs the same media and the same quote/preview card.
-      {
-        const where = 'kind = ? AND post_id = ? AND actor_uri = ? AND object_uri = ?';
-        const key = ['reply', tgt.post_id, actorUri, o.id || ''];
-        const mj = mediaFromNote(o);
-        if (mj && mj !== '[]') { try { db.prepare(`UPDATE ap_interactions SET media_json = ? WHERE ${where}`).run(mj, ...key); } catch { /* ignore */ } }
-        resolveCard(o).then((c) => {
-          if (!c) return;
-          const col = c.column === 'quote_json' ? 'quote_json' : 'embed_json';   // never a value from the wire
-          try { db.prepare(`UPDATE ap_interactions SET ${col} = ? WHERE ${where}`).run(c.json, ...key); } catch { /* ignore */ }
-        }).catch(() => { /* best-effort */ });
-      }
-      {
-        // Private (followers/direct) replies push as a DM ping WITHOUT content
-        // (the push service should never carry private text, design decision);
-        // public replies carry a short snippet.
-        const ctx = pushPostCtx(tgt.post_id);
-        const vis = noteVisibility(o);
-        const priv = vis === 'direct' || vis === 'followers';
-        if (ctx) {
-          const L = pushLang(ctx.site);
-          const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
-          if (priv) pushEvent(ctx.site, { type: 'dm', title: i18nT(L, 'push.n_dm_t'), body: i18nT(L, 'push.n_dm_b', { who }), url: `${pushPrefix(ctx.site)}/messages` });
-          else pushEvent(ctx.site, { type: 'reply', title: i18nT(L, 'push.n_reply_t', { title: ctx.title }), body: `${who}: ${HtmlSanitizerService.toPlainText(html).slice(0, 90)}`, url: ctx.url });
-        }
-      }
-      return 202;
-    }
-    // Home timeline (client): a top-level post from an account we follow.
-    if (actorUri && !isLocalActor && belongsInTimeline(o)) {
-      let subs = []; try { subs = db.prepare('SELECT slug, auto_boost FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist yet */ }
-      if (subs.length) {
-        const ai = actorInfo(await resolveActor(actorUri), actorUri);
-        const { html, atts: _atts, url: _url } = timelineFields(o);
-        const media = JSON.stringify(_atts);
-        const poll = parsePoll(o); // a Question (fediverse poll) → cache its options/counts
-        // "Feature" = show in the Cirkel (local only). We do NOT auto-Announce
-        // incoming posts to the fediverse — that flooded followers. Boosting to the
-        // fediverse is only ever a deliberate, manual per-post action (the 🔁 on
-        // the timeline).
-        for (const s of subs) {
-          tlStmts().ins.run(o.id, s.slug, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, _url, o.published || null, media, o.sensitive ? 1 : 0, contentWarning(o));
-          // FEP-633c §2.2: register the ward hint on the stored object (no action yet).
-          if (Guardianship.objectHasGuardians(o)) { try { db.prepare('UPDATE ap_timeline SET has_guardians = 1 WHERE id = ? AND slug = ?').run(o.id, s.slug); } catch { /* ignore */ } }
-          // FEP-9098: keep the note's custom-emoji tags so the C2S inbox read can serve them.
-          { const ej = extractEmojiTags(o.tag); if (ej) { try { db.prepare('UPDATE ap_timeline SET emoji_json = ? WHERE id = ? AND slug = ?').run(ej, o.id, s.slug); } catch { /* ignore */ } } }
-          storeAuthorEmoji(o.id, s.slug, ai);   // custom-emoji display name for the byline
-
-          // FEP-e232 + FEP-044f: keep the note's object-link/quote tags for the same read.
-          { const lj = extractLinkJson(o); if (lj) { try { db.prepare('UPDATE ap_timeline SET link_json = ? WHERE id = ? AND slug = ?').run(lj, o.id, s.slug); } catch { /* ignore */ } } }
-          if (poll) { try { db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(poll), o.id, s.slug); } catch { /* ignore */ } }
-        }
-        // FEP-044f embedded quote card: resolve the quoted post out of band so
-        // the inbox response is not blocked on a remote fetch. Best-effort.
-        if (quoteHrefOf(o)) {
-          const slugs = subs.map((s) => s.slug);
-          resolveQuote(o).then((qj) => {
-            if (!qj) return;
-            for (const sl of slugs) { try { db.prepare('UPDATE ap_timeline SET quote_json = ? WHERE id = ? AND slug = ?').run(qj, o.id, sl); } catch { /* ignore */ } }
-          }).catch(() => { /* best-effort */ });
-        } else {
-          // No fediverse quote: try an EXTERNAL embed (oEmbed / known provider),
-          // thumbnail-only. Also out of band, and stored for everyone; the gate
-          // that decides who may SEE it is applied at serve time (§5.3-style
-          // gated feature, see the inbox read).
-          const slugs = subs.map((s) => s.slug);
-          resolveExternalEmbed(o.content).then((ej) => {
-            if (!ej) return;
-            for (const sl of slugs) { try { db.prepare('UPDATE ap_timeline SET embed_json = ? WHERE id = ? AND slug = ?').run(ej, o.id, sl); } catch { /* ignore */ } }
-          }).catch(() => { /* best-effort */ });
-        }
-        console.log('[AP] timeline +', actorUri, 'x' + subs.length);
-      }
-    }
-    // Een ANTWOORD van iemand die we volgen: bewaar de URI (shaer-e9g). Zo'n
-    // bericht komt hier gewoon binnen, ondertekend door de schrijver zelf, maar
-    // belongsInTimeline houdt het uit de Krant en daarna raakten we het kwijt.
-    // Kwam er later een doorgestuurd antwoord OP dat bericht, dan kenden we de
-    // ouder niet en wezen we het af -- terwijl we hem wel degelijk hadden gehad.
-    // Er verandert niets aan wat we tonen of van vreemden aannemen: de schrijver
-    // moet iemand zijn die je zelf bent gaan volgen.
-    if (actorUri && !isLocalActor && o.id && o.inReplyTo && noteVisibility(o) !== 'direct' && isFollowedActor(actorUri)) {
-      rememberNoteUri(o.id);
-    }
-    // Mentioned in a post that is NOT a reply to our content (a reply to us already returned
-    // above): store a mention notification for each of our actors named in the Mention tags.
-    // Requires our own base prefix on the tag href — /ap/users/<slug> on a REMOTE host is
-    // someone else's actor, not ours.
-    // Een markering op een hulpvraag (shaer-lgo): een mede-guardian laat weten
-    // dat hij ernaar kijkt, of dat het is afgehandeld. Gewone directe note met
-    // een shaer:-markering, net als de zwaai -- dus die komt hier langs. VOOR de
-    // mention-opslag, want dit is staat en geen bericht om te bewaren; de ward
-    // krijgt hem wel als bericht te lezen, en dat gebeurt hieronder.
-    if (actorUri && !isLocalActor) {
-      const mark = Guardianship.help.parseMarker(o);
-      if (mark) {
-        // WIE MAG DIT (shaer-gt70). Hier stond alleen "de actor is niet lokaal",
-        // en dat is geen poort: elke ondertekende actor die de URI van een
-        // hulpvraag kende kon hem op 'handled' zetten. Afgehandeld kent geen
-        // terugdraai en de vraag verdwijnt daarna uit de teller van ELKE
-        // guardian -- een vreemde kon dus de noodknop van een kind uitzetten.
-        //
-        // Ondertekening zegt WIE, niet OF HET MAG. Die tweede laag stond er niet.
-        //
-        // DE WARD IS DE BRON VAN WAARHEID over wie zijn guardians zijn; onze
-        // eigen tabel kent alleen ONZE relatie. existingGuardiansOf stelt die
-        // vraag op de goede plek: lokaal opzoeken als wij de ward hosten,
-        // anders shaer:guardians van zijn actor.
-        //
-        // En WELKE ward dat is komt uit onze EIGEN administratie -- de
-        // hulpvraag zoals wij hem opsloegen -- nooit uit wat de afzender
-        // beweert. Kennen we die hulpvraag niet, dan is er niets te markeren.
-        const vraag = helpRequestRow(mark.noteUri);
-        if (!vraag) {
-          console.warn('[AP] help-markering voor een onbekende hulpvraag, genegeerd:', actorUri, '→', mark.noteUri);
-        } else if (!(await isGuardianOfWard(actorUri, vraag.actor_uri))) {
-          console.warn('[AP] help-markering van iemand die geen guardian van deze ward is, geweigerd:', actorUri, '→', mark.noteUri);
-        } else {
-          const ai = actorInfo(await resolveActor(actorUri).catch(() => null), actorUri);
-          Guardianship.help.record(mark.noteUri, actorUri, mark.kind, ai && ai.handle);
-          // Het paneel dat de hulpvraag HOUDT wordt gewekt, en dat is
-          // `vraag.slug`. Hier stond `slug`, en die bestaat in deze scope niet:
-          // de markering werd vastgelegd en daarna gooide de handler een
-          // ReferenceError, dus het paneel hoorde het nooit en de rest van de
-          // verwerking van deze activiteit viel weg. Gemeten, niet geredeneerd.
-          // slugParam zou hier ook fout zijn: op de gedeelde inbox is die null.
-          wakeGuardian(vraag.slug);   // een mede-guardian pakte iets op: het paneel hoort het meteen
-          console.log('[AP] help', mark.kind, actorUri, '→', mark.noteUri);
-        }
-      }
-    }
-    if (actorUri && !isLocalActor && o.id) {
-      const slugs = localMentionSlugs(o.tag, base);
-      if (slugs.length) {
-        const ai = actorInfo(await resolveActor(actorUri), actorUri);
-        const html = HtmlSanitizerService.sanitize(o.content || '');
-        // FEP-633c 5.2.1: a ward's call for help rides a direct mention; the
-        // flag is stored so the Guardian PWA's message centre can list it.
-        const help = Guardianship.isHelpRequest(o);
-        const wave = Guardianship.isWave(o);
-        const hasG = Guardianship.objectHasGuardians(o);   // §2.2 hint, register-only
-        // FEP-633c 3.6.1: a guardian declares itself away to its ward, on the
-        // same direct note the mention below stores (so the kid also reads it
-        // as an ordinary message). Recorded only from an actual guardian of
-        // the addressed ward, and only with an end: an absence without an end
-        // is logged and dropped, never guessed.
-        if (Guardianship.availability.isAway(o)) {
-          const until = Guardianship.availability.parseEndTime(o.endTime);
-          for (const slug of slugs) {
-            const isG = (() => { try { return Guardianship.listGuardians(slug).some((g) => g.other_uri === actorUri); } catch { return false; } })();
-            if (!isG) continue;
-            if (!until || until <= Date.now()) { console.warn('[AP] away without a (future) end ignored (3.6.1):', actorUri, '→', slug); continue; }
-            Guardianship.availability.declareAway(slug, actorUri, until);
-            console.log('[AP] guardian declared away (3.6.1):', actorUri, '→', slug, 'until', new Date(until).toISOString());
-          }
-        }
-        // Een kind dat zelf om een poort vraagt (shaer-8ru). Zelfde weg als de
-        // afwezigheidsmelding: een gewone directe note met een shaer:-markering,
-        // per genoemde ontvanger afgehandeld.
-        //
-        // ALLEEN VAN EEN EIGEN WARD. Een verzoek van een vreemde is geen vraag
-        // maar een onbekende die iets over jouw instellingen wil zeggen -- dat
-        // hoort in geen enkele lijst te belanden waar een guardian op afgaat.
-        {
-          const req = Guardianship.gatereq.parseRequest(o);
-          if (req) {
-            for (const slug of slugs) {
-              const mijn = (() => { try { return Guardianship.listWards(slug).some((w) => w.other_uri === actorUri); } catch { return false; } })();
-              if (!mijn) { console.warn('[AP] gate request from someone who is not our ward, ignored:', actorUri, '→', slug); continue; }
-              Guardianship.gatereq.record(slug, actorUri, req.feature, o.id);
-              wakeGuardian(slug);   // het kind vroeg om een poort
-              console.log('[AP] gate request', req.feature, actorUri, '→', slug);
-            }
-          }
-        }
-        for (const slug of slugs) {
-          try {
-            // De OUDER gaat mee (Robins melding, 26-8). Hij stond nergens in
-            // deze rij, dus een antwoord binnen een gesprek kwam bij de client
-            // aan alsof het een gesprek begon: de app kan een keten alleen
-            // teruglopen langs inReplyTo, en die was leeg.
-            //
-            // Alleen een http(s)-adres, langs dezelfde poort als `url`: een
-            // inReplyTo komt van een vreemde en mag geen ander schema
-            // binnensmokkelen. AS2 staat een string of een object toe, dus
-            // allebei uitpakken -- alleen de string erkennen zou hetzelfde gat
-            // laten voor iedereen die de objectvorm stuurt.
-            const ouder = safeUrl(typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id)) || null;
-            const r = db.prepare(`INSERT OR IGNORE INTO ap_mentions (slug, object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, published, in_reply_to, help_request, wave, has_guardians, emoji_json, actor_emoji_json, media_json, created_at)
-                                  VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,${NU_ISO})`)
-              .run(slug, o.id, safeUrl(o.url) || null, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.published || null, ouder, help ? 1 : 0, wave ? 1 : 0, hasG ? 1 : 0,
-                extractEmojiTags(o.tag), emojiJsonOf(ai.emojis), mediaFromNote(o));
-            if (r.changes) {
-              // The quote / link-preview card resolves out of band (a remote
-              // fetch), exactly as it does for a timeline post, so the inbox
-              // answer is never blocked on it.
-              resolveCard(o).then((c) => {
-                if (!c) return;
-                const col = c.column === 'quote_json' ? 'quote_json' : 'embed_json';   // never a value from the wire
-                try { db.prepare(`UPDATE ap_mentions SET ${col} = ? WHERE slug = ? AND object_uri = ?`).run(c.json, slug, o.id); } catch { /* ignore */ }
-              }).catch(() => { /* best-effort */ });
-              console.log('[AP] mention', actorUri, '→', slug, help ? '(help request)' : '');
-              const vis = noteVisibility(o);
-              const priv = vis === 'direct' || vis === 'followers';
-              const L = pushLang(slug);
-              const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
-              // Same privacy rule as replies: private mentions push without content.
-              // A help request pushes as its own alert type, aimed at the
-              // Guardian PWA's message centre.
-              if (help) pushEvent(slug, { type: 'help', title: i18nT(L, 'push.n_help_t'), body: i18nT(L, 'push.n_help_b', { who }), url: '/guardian' });
-              else if (priv) pushEvent(slug, { type: 'dm', title: i18nT(L, 'push.n_dm_t'), body: i18nT(L, 'push.n_dm_b', { who }), url: `${pushPrefix(slug)}/messages` });
-              else pushEvent(slug, { type: 'reply', title: i18nT(L, 'push.n_mention_t'), body: `${who}: ${HtmlSanitizerService.toPlainText(html).slice(0, 90)}`, url: `${pushPrefix(slug)}/messages` });
-            }
-          } catch { /* ignore */ }
-        }
-      }
-    }
-    return 202;
-  }
-  // A remote post we cached was edited upstream → refresh our cached copy. This is the
-  // push-based edit-sync that keeps the Cirkel/timeline fresh without polling (selfHeal
-  // does it on a version bump; this does it live). Scope to the SIGNING actor so B can't
-  // edit A's note (the signature gate guarantees claimedActor == the verified signer).
-  if (type === 'Update' && act.object && (act.object.type === 'Note' || act.object.type === 'Article' || act.object.type === 'Question')) {
-    const o = act.object;
-    if (o.id && claimedActor) {
-      const html = HtmlSanitizerService.sanitize(o.content || '');
-      const media = mediaFromNote(o);
-      try {
-        // Refresh url too (COALESCE keeps the old one if the Update omits it): a remote slug
-        // rename keeps the same AP id but changes the human url, so without this the cached
-        // post would keep linking to the old, now-dead URL.
-        const r = db.prepare('UPDATE ap_timeline SET content = ?, media_json = ?, nsfw = ?, cw = ?, url = COALESCE(?, url) WHERE id = ? AND author_uri = ?')
-          .run(html, media, o.sensitive ? 1 : 0, contentWarning(o), o.url || null, o.id, claimedActor);
-        if (r.changes) console.log('[AP] timeline update', claimedActor, '→', o.id);
-        // A poll's Update carries the fresh vote counts / closed state. Refresh per-row so each
-        // site keeps its own `voted` state while the counts/closed update to the new totals.
-        const poll = parsePoll(o);
-        if (poll) {
-          const rows = db.prepare('SELECT rowid AS rid, poll_json FROM ap_timeline WHERE id = ? AND author_uri = ?').all(o.id, claimedActor);
-          const upd = db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE rowid = ?');
-          for (const rw of rows) {
-            let voted = null; try { voted = rw.poll_json ? (JSON.parse(rw.poll_json).voted || null) : null; } catch { /* ignore */ }
-            upd.run(JSON.stringify({ ...poll, voted }), rw.rid);
-          }
-        }
-      } catch { /* ignore */ }
-      // If this note is a cached fediverse reply on one of our posts, refresh its text too.
-      try { db.prepare('UPDATE ap_interactions SET content = ? WHERE object_uri = ? AND actor_uri = ?').run(html, o.id, claimedActor); } catch { /* ignore */ }
-    }
-    return 202;
-  }
-  if (type === 'Like' || type === 'Announce') {
-    const tgt = act.object;
-    const objUrl = typeof tgt === 'string' ? tgt : (tgt && tgt.id);
-    const pid = postIdFromNoteUrl(objUrl, base);
-    if (pid && actorUri && !isLocalActor && localPostExists(pid)) {
-      // A boost/like of a non-public post is dropped, not stored: nobody
-      // outside the audience should even hold it (shaer-tqc hardening).
-      const vp = db.prepare('SELECT fan_only, ap_visibility FROM posts WHERE id = ?').get(pid);
-      if (vp && (vp.fan_only || vp.ap_visibility === 'direct' || vp.ap_visibility === 'friends')) {
-        console.log('[AP] dropped', type, 'on non-public post', pid);
-        return;
-      }
-      const ai = actorInfo(await resolveActor(actorUri), actorUri);
-      iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null, null, noteVisibility(act), null, emojiJsonOf(ai.emojis));
-      console.log('[AP]', type === 'Like' ? 'like' : 'boost', actorUri, '→', pid);
-      {
-        const ctx = pushPostCtx(pid);
-        if (ctx) {
-          const L = pushLang(ctx.site);
-          const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
-          if (type === 'Like') pushEvent(ctx.site, { type: 'like', title: i18nT(L, 'push.n_like_t'), body: i18nT(L, 'push.n_like_b', { who, title: ctx.title }), url: ctx.url });
-          else pushEvent(ctx.site, { type: 'boost', title: i18nT(L, 'push.n_boost_t'), body: i18nT(L, 'push.n_boost_b', { who, title: ctx.title }), url: ctx.url });
-        }
-      }
-    } else if (type === 'Announce' && objUrl && actorUri && !isLocalActor) {
-      // A boost FROM an account we follow, of a REMOTE post → show it in the News feed.
-      // We only STORE it for display; we NEVER auto-Announce it onward (anti-feedback-loop:
-      // re-announcing an incoming Announce would cascade boosts across the network).
-      let subs = []; try { subs = db.prepare('SELECT slug FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist */ }
-      if (subs.length) {
-        const bn = await fetchNoteAP(objUrl);
-        if (bn && bn !== 404 && (bn.type === 'Note' || bn.type === 'Article') && bn.id) {
-          const origUri = actorUriOf(bn.attributedTo);
-          // Block completeness: even if you follow the booster, drop a boost whose ORIGINAL
-          // author is blocked — otherwise a block is bypassed via someone else's boost.
-          if (origUri && isBlockedAny(origUri)) { console.log('[AP] timeline boost dropped (blocked origin)', origUri, 'via', actorUri); return 202; }
-          const oai = actorInfo(await resolveActor(origUri), origUri);
-          const html = HtmlSanitizerService.sanitize(bn.content || '');
-          const media = mediaFromNote(bn);
-          const booster = actorInfo(await resolveActor(actorUri), actorUri);
-          for (const s of subs) {
-            // published = now → the boost shows as fresh activity at the top (Mastodon shows
-            // reblogs at reblog-time, not the original's date). INSERT OR IGNORE: if we already
-            // have the note (e.g. we also follow the author), keep it and DON'T relabel it.
-            let inserted = false;
-            try { const r = tlStmts().ins.run(bn.id, s.slug, origUri || '', oai.name, oai.handle, oai.icon, oai.url, html, bn.url || null, new Date().toISOString(), media, bn.sensitive ? 1 : 0, contentWarning(bn)); inserted = r.changes > 0; } catch { /* ignore */ }
-            if (inserted) { try { db.prepare('UPDATE ap_timeline SET reblog_name = ?, reblog_handle = ?, reblog_icon = ?, reblog_emoji_json = ? WHERE slug = ? AND id = ?').run(booster.name, booster.handle, booster.icon, (booster.emojis && Object.keys(booster.emojis).length) ? JSON.stringify(booster.emojis) : null, s.slug, bn.id); } catch { /* ignore */ } }
-            storeAuthorEmoji(bn.id, s.slug, oai);   // custom-emoji display name for the byline
-            // A boost carries the same renderable tags as a Create: capture the
-            // note's content emojis (FEP-9098) and object links / quote (FEP-e232/
-            // 044f) so boosted posts render like any other, not as raw shortcodes.
-            { const ej = extractEmojiTags(bn.tag); if (ej) { try { db.prepare('UPDATE ap_timeline SET emoji_json = ? WHERE id = ? AND slug = ?').run(ej, bn.id, s.slug); } catch { /* ignore */ } } }
-            { const lj = extractLinkJson(bn); if (lj) { try { db.prepare('UPDATE ap_timeline SET link_json = ? WHERE id = ? AND slug = ?').run(lj, bn.id, s.slug); } catch { /* ignore */ } } }
-          }
-          // FEP-044f: resolve the embedded quote card for a boosted post too
-          // (out of band, best-effort, so it does not block the inbox response).
-          if (quoteHrefOf(bn)) {
-            const slugs = subs.map((s) => s.slug);
-            resolveQuote(bn).then((qj) => {
-              if (!qj) return;
-              for (const sl of slugs) { try { db.prepare('UPDATE ap_timeline SET quote_json = ? WHERE id = ? AND slug = ?').run(qj, bn.id, sl); } catch { /* ignore */ } }
-            }).catch(() => { /* best-effort */ });
-          }
-          console.log('[AP] timeline boost +', actorUri, 'x' + subs.length);
-        }
-      }
-    }
-    return 202;
-  }
-  if (type === 'Delete') {
-    // A remote note was deleted upstream → drop it from replies AND the timeline.
-    // Scope to the SIGNING actor so actor B can't delete actor A's content (the
-    // signature gate guarantees claimedActor == the verified signer here).
-    const oid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
-    if (oid && claimedActor) {
-      try { db.prepare('DELETE FROM ap_interactions WHERE object_uri = ? AND actor_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }
-      try { db.prepare('DELETE FROM ap_timeline WHERE id = ? AND author_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }
-      // Also clear a boost/like YOU made of this now-deleted remote post (the interact-page
-      // ap_my_reactions state), so it can't stay stuck as "boosted" on a post that's gone.
-      // Guard: only when the deleter owns the note's domain (B mustn't clear your reactions
-      // to A's posts).
-      try {
-        let sameHost = false;
-        try { sameHost = new URL(oid).host === new URL(claimedActor).host; } catch { sameHost = false; }
-        if (sameHost) db.prepare('DELETE FROM ap_my_reactions WHERE target_uri = ?').run(oid);
-      } catch { /* ignore */ }
-    }
-    return 202;
-  }
-  // Accept/Reject of a Follow WE sent (client side).
-  if (type === 'Accept' && act.object) {
-    const fid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
-    let raak = 0;
-    if (fid) { try { raak = fwStmts().acc.run(fid).changes; } catch { /* ignore */ } }
-    // TERUGVAL, en die is nodig gebleken tegen Funkwhale. Een Accept hoort de
-    // Follow terug te geven die hij beantwoordt, maar Funkwhale verzint er een
-    // EIGEN id voor, in ONZE namespace:
-    //
-    //   wij stuurden   .../ap/users/dev#follow-1786161977286-bb2de32f
-    //   Funkwhale zegt .../ap/users/dev#follows/19fd8b00-8f66-...
-    //
-    // Matchen op follow_id raakt dan niets, en de volgrelatie bleef eeuwig op
-    // 'pending' staan terwijl de logregel 'accepted' riep -- een stille no-op
-    // die pas opviel toen er nooit iets binnenkwam.
-    //
-    // Het paar dat we WEL zeker weten is (deze site, deze actor): de Accept is
-    // handtekening-geverifieerd, en actorUri is de ondertekenaar. Alleen een
-    // rij die nog op pending staat wordt geraakt, dus dit kan niets anders
-    // openzetten dan een follow die wij zelf hebben verstuurd.
-    //
-    // En de slug mag NIET van slugParam afhangen: Funkwhale bezorgt op de
-    // GEDEELDE inbox, en dan is die leeg. Wie wij zijn staat in de ingesloten
-    // Follow -- die hebben wij immers zelf verstuurd, dus `object.actor` is
-    // onze eigen actor-URI.
-    let mij = slugParam;
-    if (!mij && act.object && typeof act.object === 'object') mij = slugFromActorUrl(act.object.actor);
-    if (!raak && mij && actorUri) {
-      try { raak = fwStmts().accByActor.run(mij, actorUri).changes; } catch { /* ignore */ }
-    }
-    // Eerlijk loggen: zonder treffer is er niets geaccepteerd, en dat hoort te
-    // zien te zijn in plaats van als succes voorbij te komen.
-    console.log('[AP] follow', raak ? 'accepted' : 'accept UNMATCHED', actorUri, fid ? '(' + fid + ')' : '');
-    // The moment a friendship exists is the moment the history comes along
-    // (Robins besluit, 30-7): delivery cannot reach into the past, so the
-    // fresh follower pulls the outbox, signed, and the other side now serves
-    // the friends-only posts too.
-    if (slugParam && actorUri) backfillFromOutbox(slugParam, actorUri).catch(() => { /* best-effort */ });
-    return 202;
-  }
-  if (type === 'Reject' && act.object) {
-    const who = actorUri;
-    if (who && slugParam) { try { fwStmts().del.run(slugParam, who); } catch { /* ignore */ } }
-    return 202;
-  }
-
-  // Zeg ook WAT er viel. Een kale "Create (ignored)" verbergt het verschil
-  // tussen een soort die we bewust overslaan en een die we niet kennen -- en
-  // dat verschil was precies de vraag bij Funkwhale, dat Create(Audio) stuurt
-  // waar deze inbox alleen Note, Article en Question aanneemt.
-  const objType = act.object && typeof act.object === 'object' ? act.object.type : (typeof act.object === 'string' ? '<uri>' : null);
-  console.log('[AP] inbox', type || 'unknown', objType ? '(' + objType + ')' : '', '→', slugParam || 'shared',
-    'from', ip, 'by', claimedActor || '?', '(ignored)');
-  return 202;
-}
-
Index: src/services/ap-polls.js
===================================================================
--- src/services/ap-polls.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,217 +1,0 @@
-/**
- * ap-polls.js — de peilingen (stap 8 van shaer-drc).
- *
- * Beide kanten van een fediverse-poll:
- *   - VREEMDE polls: parsePoll (het AS2 Question-formaat naar onze compacte
- *     vorm) en de twee stemhandelingen (voteOnPoll uit de tijdlijncache,
- *     voteOnRemotePoll op URL).
- *   - EIGEN polls: de definitie op posts.poll_json, de telling uit de
- *     stembiljetten (poll_votes), de Question-vorm op een note, het innemen
- *     van een biljet en de gebundelde Update(Question) naar de volgers.
- *
- * Vier werktuigen uit de dienstlaag komen via wirePolls binnen; de rest wijst
- * omlaag (db, ap-core, ap-transport).
- */
-import db from '../config/database.js';
-import { actorId, AP_CONTEXT } from './ap-core.js';
-import { fetchActor, getOrCreateKeys, deliverWithRetry } from './ap-transport.js';
-
-// De werktuigen uit de dienstlaag; ActivityPubService vult ze onderaan.
-let deliverUpdate, rid, movedRefusal, actorUriOf;
-export function wirePolls(deps) {
-  ({ deliverUpdate, rid, movedRefusal, actorUriOf } = deps);
-}
-
-// Parse a fediverse poll (an ActivityStreams `Question` — the Mastodon-standard poll form)
-// into our compact shape. `oneOf` = single choice, `anyOf` = multiple; each option is a Note
-// with a `name` and a `replies` collection whose `totalItems` is that option's vote count.
-export function parsePoll(o) {
-  if (!o || o.type !== 'Question') return null;
-  const raw = Array.isArray(o.oneOf) ? o.oneOf : (Array.isArray(o.anyOf) ? o.anyOf : null);
-  if (!raw || !raw.length) return null;
-  const options = raw.slice(0, 12).map((opt) => ({
-    name: String((opt && opt.name) || '').slice(0, 300),
-    count: Math.max(0, Number(opt && opt.replies && opt.replies.totalItems) || 0),
-  })).filter((x) => x.name);
-  if (!options.length) return null;
-  const endTime = o.endTime || (typeof o.closed === 'string' ? o.closed : null);
-  const closed = !!o.closed || (endTime ? Date.parse(endTime) <= Date.now() : false);
-  return { multiple: Array.isArray(o.anyOf), options, endTime, closed, voters: Number(o.votersCount) || null, voted: null };
-}
-
-// ── Polls WE host (a local post with a poll) ──────────────────────
-// Parse the poll definition stored on our own post (posts.poll_json). Counts are
-// NOT stored here — they're derived from the poll_votes ballots so a re-render always
-// reflects the authoritative tally.
-export function parseOwnPoll(pollJson) {
-  if (!pollJson) return null;
-  let d; try { d = typeof pollJson === 'string' ? JSON.parse(pollJson) : pollJson; } catch { return null; }
-  if (!d || !Array.isArray(d.options)) return null;
-  const options = d.options.map((o) => ({ name: String((o && o.name != null ? o.name : o) || '').slice(0, 300) })).filter((o) => o.name);
-  if (options.length < 2) return null;
-  const endTime = d.endTime || null;
-  const closed = !!d.closed || (endTime ? Date.parse(endTime) <= Date.now() : false);
-  return { multiple: !!d.multiple, options, endTime, closed };
-}
-
-// Live tally of a hosted poll from its ballots: per-option counts + unique voters.
-export function pollTally(postId) {
-  const counts = {}; let voters = 0;
-  try {
-    for (const r of db.prepare('SELECT choice, COUNT(*) AS n FROM poll_votes WHERE post_id = ? GROUP BY choice').all(postId)) counts[r.choice] = r.n;
-    voters = db.prepare('SELECT COUNT(DISTINCT actor_uri) AS n FROM poll_votes WHERE post_id = ?').get(postId).n || 0;
-  } catch { /* table may not exist yet */ }
-  return { counts, voters };
-}
-
-// Render-ready view of a hosted poll (options with counts + percentages, totals, state).
-// Voting is fediverse-only, so this is display-only on the site.
-export function ownPollView(post) {
-  const poll = parseOwnPoll(post && post.poll_json);
-  if (!poll) return null;
-  const { counts, voters } = pollTally(post.id);
-  const total = Object.values(counts).reduce((a, b) => a + b, 0);
-  const denom = poll.multiple ? voters : total; // multiple-choice %: share of voters (can sum >100%)
-  const options = poll.options.map((o) => {
-    const count = counts[o.name] || 0;
-    return { name: o.name, count, pct: denom ? Math.round((count / denom) * 100) : 0 };
-  });
-  return { multiple: poll.multiple, options, total, voters, endTime: poll.endTime, closed: poll.closed };
-}
-
-// Attach the AS2 Question shape to a note built for a hosted poll. Mastodon renders a
-// status with either media OR a poll (never both), so a poll federates as content +
-// options with no media attachment. oneOf = single choice, anyOf = multiple.
-export function applyPollToNote(note, postId, poll) {
-  const { counts, voters } = pollTally(postId);
-  const opts = poll.options.map((o) => ({
-    type: 'Note',
-    name: o.name,
-    replies: { type: 'Collection', totalItems: counts[o.name] || 0 },
-  }));
-  note.type = 'Question';
-  note[poll.multiple ? 'anyOf' : 'oneOf'] = opts;
-  if (poll.endTime) note.endTime = new Date(poll.endTime).toISOString();
-  // Once closed, Mastodon expects a `closed` timestamp (the effective end).
-  if (poll.closed) note.closed = poll.endTime ? new Date(poll.endTime).toISOString() : new Date().toISOString();
-  note.votersCount = voters;
-  delete note.attachment;   // media ATTACHMENTS + a poll are mutually exclusive on Mastodon
-  // Keep note.image: it's the cover, which Mastodon ignores on a Question anyway
-  // (same as on any Note) but Klonkt reads to show the cover in feeds/the Cirkel.
-  // Deleting it stripped the cover off every boosted poll.
-  return note;
-}
-
-// Record an inbound ballot on one of OUR polls. A vote arrives as a Create(Note) whose
-// `name` is the chosen option and `inReplyTo` is our poll note — the Mastodon-standard
-// vote form. Returns { handled } — handled=true means it was addressed to a poll (so the
-// caller must NOT also store it as a reply), false means "not a poll, fall through".
-export function recordPollBallot(postId, actorUri, rawChoice) {
-  const choice = String(rawChoice == null ? '' : rawChoice).slice(0, 300);
-  if (!choice) return { handled: false };
-  let post; try { post = db.prepare('SELECT poll_json FROM posts WHERE id = ?').get(postId); } catch { return { handled: false }; }
-  const poll = post && parseOwnPoll(post.poll_json);
-  if (!poll) return { handled: false };               // not a poll → let the reply logic handle it
-  if (poll.closed) return { handled: true };          // voting closed → drop
-  if (!poll.options.some((o) => o.name === choice)) return { handled: true }; // unknown option → drop
-  try {
-    // Single choice = one ballot per actor: ignore a later/different vote. Multiple choice
-    // allows one ballot per distinct option (the UNIQUE(post,actor,choice) dedupes repeats).
-    if (!poll.multiple && db.prepare('SELECT 1 FROM poll_votes WHERE post_id = ? AND actor_uri = ? LIMIT 1').get(postId, actorUri)) return { handled: true };
-    db.prepare('INSERT OR IGNORE INTO poll_votes (post_id, actor_uri, choice) VALUES (?, ?, ?)').run(postId, actorUri, choice);
-  } catch { return { handled: true }; }
-  schedulePollUpdate(postId);
-  return { handled: true };
-}
-
-// Coalesce a burst of votes into ONE Update(Question) per poll: the first vote schedules a
-// refresh ~15s out; further votes in that window ride the same pending update (which carries
-// the accumulated tally). Non-follower voters re-fetch the Question (live tally) themselves.
-const _pollUpdTimers = new Map();
-function schedulePollUpdate(postId) {
-  if (_pollUpdTimers.has(postId)) return;
-  const t = setTimeout(() => { _pollUpdTimers.delete(postId); deliverPollUpdate(postId).catch(() => { /* best-effort */ }); }, 15000);
-  if (t.unref) t.unref();
-  _pollUpdTimers.set(postId, t);
-}
-
-// Push the fresh poll tally (or closed state) to followers as Update(Question).
-export async function deliverPollUpdate(postId) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !postId) return;
-  let post, site;
-  try {
-    post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
-    if (!post || !post.poll_json) return;
-    site = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id);
-  } catch { return; }
-  if (site) await deliverUpdate(site, post);
-}
-
-// Vote on a remote fediverse poll (a cached Question). A ballot = a Create(Note) carrying only a
-// `name` (the chosen option) + inReplyTo the Question, addressed to the poll's author — the
-// Mastodon-standard vote. Records our choice locally + optimistically bumps the counts; the
-// author's Update(Question) refreshes the authoritative totals when it arrives.
-export async function voteOnPoll(site, questionId, choices) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !site || !site.slug || !questionId) return { error: 'config' };
-  let row; try { row = db.prepare('SELECT author_uri, poll_json FROM ap_timeline WHERE id = ? AND slug = ? LIMIT 1').get(questionId, site.slug); } catch { /* ignore */ }
-  if (!row || !row.poll_json) return { error: 'not_found' };
-  let poll; try { poll = JSON.parse(row.poll_json); } catch { return { error: 'not_found' }; }
-  if (poll.closed) return { error: 'closed' };
-  if (poll.voted) return { error: 'already' };
-  const valid = new Set(poll.options.map((o) => o.name));
-  const picks = (Array.isArray(choices) ? choices : [choices]).map(String).filter((c) => valid.has(c));
-  if (!picks.length) return { error: 'invalid' };
-  const chosen = poll.multiple ? [...new Set(picks)] : [picks[0]];
-  const me = actorId(base, site.slug);
-  const keys = getOrCreateKeys(site.slug);
-  const authorUri = row.author_uri || null;
-  const author = authorUri ? await fetchActor(authorUri).catch(() => null) : null;
-  const inbox = author && (author.inbox || (author.endpoints && author.endpoints.sharedInbox));
-  if (!inbox) return { error: 'unreachable' };
-  for (const name of chosen) {
-    const nid = `${me}/votes/${Date.now()}-${rid()}`;
-    const note = { id: nid, type: 'Note', attributedTo: me, to: authorUri ? [authorUri] : [], name, inReplyTo: questionId, published: new Date().toISOString() };
-    const create = { '@context': AP_CONTEXT, id: `${nid}/activity`, type: 'Create', actor: me, to: note.to, object: note };
-    deliverWithRetry(site.slug, inbox, create, `${me}#main-key`, keys.private_pem);
-  }
-  // Local optimistic update (authoritative counts arrive via the author's Update(Question)).
-  poll.voted = poll.multiple ? chosen : chosen[0];
-  for (const o of poll.options) if (chosen.includes(o.name)) o.count = (o.count || 0) + 1;
-  if (poll.voters != null) poll.voters += 1;
-  try { db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(poll), questionId, site.slug); } catch { /* ignore */ }
-  return { ok: true };
-}
-
-// Vote on ANY fediverse poll by URL (the interact page) — no timeline cache needed. Fetches
-// the Question fresh, validates the choice(s), and casts the Mastodon-standard ballot (a
-// Create(Note) with `name` + inReplyTo) straight to the poll's author. Used for polls you find
-// by URL, not just ones from accounts you follow (which go through voteOnPoll via /news).
-export async function voteOnRemotePoll(site, questionUrl, choices) {
-  const _mv = movedRefusal(site, 'poll-vote'); if (_mv) return _mv;
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !site || !site.slug || !/^https?:\/\//i.test(String(questionUrl || ''))) return { error: 'config' };
-  const q = await fetchActor(questionUrl).catch(() => null); // AP GET (SSRF-guarded)
-  if (!q || q.type !== 'Question' || !q.id) return { error: 'not_found' };
-  const poll = parsePoll(q);
-  if (!poll) return { error: 'not_found' };
-  if (poll.closed) return { error: 'closed' };
-  const valid = new Set(poll.options.map((o) => o.name));
-  const picks = (Array.isArray(choices) ? choices : [choices]).map(String).filter((c) => valid.has(c));
-  if (!picks.length) return { error: 'invalid' };
-  const chosen = poll.multiple ? [...new Set(picks)] : [picks[0]];
-  const authorUri = actorUriOf(q.attributedTo);
-  const author = authorUri ? await fetchActor(authorUri).catch(() => null) : null;
-  const inbox = author && (author.inbox || (author.endpoints && author.endpoints.sharedInbox));
-  if (!inbox) return { error: 'unreachable' };
-  const me = actorId(base, site.slug);
-  const keys = getOrCreateKeys(site.slug);
-  for (const name of chosen) {
-    const nid = `${me}/votes/${Date.now()}-${rid()}`;
-    const note = { id: nid, type: 'Note', attributedTo: me, to: [authorUri], name, inReplyTo: q.id, published: new Date().toISOString() };
-    const create = { '@context': AP_CONTEXT, id: `${nid}/activity`, type: 'Create', actor: me, to: note.to, object: note };
-    deliverWithRetry(site.slug, inbox, create, `${me}#main-key`, keys.private_pem);
-  }
-  return { ok: true };
-}
Index: src/services/ap-reactions.js
===================================================================
--- src/services/ap-reactions.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,313 +1,0 @@
-/**
- * ap-reactions.js — het reactiecluster (stap 6 van shaer-drc).
- *
- * De waarheid over "heb ik hierop gereageerd" (ap_my_reactions), de afgeleide
- * vlaggen op ap_timeline die setReaction daaruit bijhoudt, de eenmalige
- * migratie, en de boost-upsert die een vreemde post de tijdlijn in trekt.
- *
- * Twee koppelingen, allebei bewust zo:
- *   - tlStmts komt STATISCH uit ap-timeline: reacties schrijven de
- *     tijdlijnvlaggen, dus die pijl wijst een kant op en mag gewoon een import
- *     zijn.
- *   - de omgekeerde pijl (de tijdlijn-leeskant heeft getReactionsFor nodig)
- *     blijft de injectie via wireTimeline in ActivityPubService -- twee
- *     zustermodules die elkaar importeren zou precies de kring zijn die
- *     shaer-drc vermijdt.
- * movedLock komt uit de dienstlaag (FEP-7628) en dus via wireReactions binnen.
- */
-import db from '../config/database.js';
-import { tlStmts } from './ap-timeline.js';
-
-// Het ene werktuig uit de dienstlaag; ActivityPubService vult het onderaan.
-let movedLock;
-export function wireReactions(deps) {
-  ({ movedLock } = deps);
-}
-
-// Your like/boost state on a REMOTE post (interact page toggles).
-export function setMyReaction(slug, uri, kind, on) {
-  if (on) db.prepare('INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind) VALUES (?,?,?)').run(slug, uri, kind);
-  else db.prepare('DELETE FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ? AND kind = ?').run(slug, uri, kind);
-}
-export function getMyReactions(slug, uri) {
-  const rows = (slug && uri) ? db.prepare('SELECT kind FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ?').all(slug, uri) : [];
-  return { liked: rows.some((r) => r.kind === 'like'), boosted: rows.some((r) => r.kind === 'boost') };
-}
-
-// AFGELEIDE, GEEN BRON (shaer-9e9). De waarheid over "heb ik hierop gereageerd"
-// staat in ap_my_reactions; deze vlaggen worden daaruit bijgehouden door
-// setReaction en door niets anders. Roep ze niet los aan -- dan schrijf je de
-// helft, en dat is precies hoe shaer:liked maandenlang false bleef (04aca12).
-//
-// ap_timeline.boosted verdient zijn bestaan wel: hij staat in de WHERE van de
-// Cirkel-feed (getCirkelPosts) en in boostedCount, dus hij is een index en geen
-// kopie. ap_timeline.liked wordt nergens als verzameling bevraagd en kan weg
-// zodra fase 2 lang genoeg goed staat; hij is nu nog het vangnet waarmee
-// terugdraaien een code-revert blijft in plaats van dataherstel.
-let _markBoost, _unmarkBoost, _boostedCount;
-export function markBoosted(slug, noteId) {
-  try { if (!_markBoost) _markBoost = db.prepare('UPDATE ap_timeline SET boosted = 1 WHERE slug = ? AND id = ?'); _markBoost.run(slug, noteId); } catch { /* ignore */ }
-}
-export function unmarkBoosted(slug, noteId) {
-  try { if (!_unmarkBoost) _unmarkBoost = db.prepare('UPDATE ap_timeline SET boosted = 0 WHERE slug = ? AND id = ?'); _unmarkBoost.run(slug, noteId); } catch { /* ignore */ }
-}
-let _markLike, _unmarkLike;
-export function markLiked(slug, noteId) {
-  try { if (!_markLike) _markLike = db.prepare('UPDATE ap_timeline SET liked = 1 WHERE slug = ? AND id = ?'); _markLike.run(slug, noteId); } catch { /* ignore */ }
-}
-export function unmarkLiked(slug, noteId) {
-  try { if (!_unmarkLike) _unmarkLike = db.prepare('UPDATE ap_timeline SET liked = 0 WHERE slug = ? AND id = ?'); _unmarkLike.run(slug, noteId); } catch { /* ignore */ }
-}
-/**
- * Zet een reactie van JOU op een object. Dit hoort het enige schrijfpad te zijn
- * (shaer-9e9): de tussentabel ap_my_reactions is de waarheid, de vlaggen op
- * ap_timeline zijn de afgeleide. Zolang markLiked en broers los aanroepbaar
- * blijven kan een aanroeper ze vergeten, en dat is niet hypothetisch -- precies
- * dat leverde de shaer:liked-bug op (04aca12).
- *
- * `opts.note` is de opgeloste remote note bij een boost. Die is niet optioneel
- * uit netheid: een boost moet de post je tijdlijn IN trekken als je de auteur
- * niet volgt, anders heeft de vlag geen rij om op te landen en verschijnt de
- * boost nergens -- ook niet in de Cirkel.
- *
- * `opts.flagUri` bestaat omdat de twee bronnen vandaag verschillend gesleuteld
- * worden: de tussentabel op de URI die de client stuurde, de vlag op de
- * opgeloste object-URI. Meestal zijn die gelijk, maar niet gegarandeerd. Deze
- * naad houdt fase 1 gedragsbehoudend; het samentrekken van die twee sleutels is
- * werk voor fase 2, mét datamigratie.
- */
-// Reactie-migratie (shaer-9e9). Draait bij boot, EEN keer per bump, net als
-// selfHealTimeline. Bewust automatisch: klonkt-update tilt een hele vloot in een
-// stap naar nieuwe code, en een handmatig script per instance wordt vergeten --
-// terwijl het falen stil is (een reactie die niemand meer ziet geeft geen fout).
-// v2 haalt de derde bron erbij: ap_interactions.acted_* (shaer-ipb). Een bump
-// laat alle stappen opnieuw lopen, en dat mag -- ze zijn alle drie idempotent.
-const REACTIONS_MIGRATION_VERSION = 2;
-
-/**
- * Brengt alle reacties naar de tussentabel, onder de canonieke object-URI.
- *
- * Twee stappen, en ze zijn allebei nodig:
- *
- *  1. HERSLEUTELEN. De oude interact-route bewaarde de URI waarmee je binnenkwam
- *     en de bookmarklet geeft window.location.href door, dus de permalink. Sinds
- *     canonicalReactionUri wordt er op de object-URI gezocht, waardoor die rijen
- *     wees zouden zijn. De created_at reist mee: bij hersleutelen weten we
- *     wanneer je reageerde, bij aanvullen niet.
- *  2. AANVULLEN vanuit de afgeleide kolommen. Alles wat op oude code via de
- *     Krant is gegeven staat alleen daar; zonder deze stap toont het als
- *     niet-gereageerd en klikt een gebruiker opnieuw -- met een tweede Like de
- *     fediverse in als gevolg.
- *
- * Idempotent. Geeft terug wat er gebeurd is, zodat het script het kan tonen.
- */
-export function migrateReactions(opts = {}) {
-  const uit = { hersleuteld: 0, aangevuld: 0, reacties: 0, overgeslagen: false };
-  try {
-    if (!opts.force) {
-      const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('reactions_migration_version');
-      const cur = r ? (parseInt(r.value, 10) || 0) : 0;
-      if (cur >= REACTIONS_MIGRATION_VERSION) { uit.overgeslagen = true; return uit; }
-    }
-  } catch { return uit; }   // geen app_settings → deze database is te oud om aan te raken
-
-  // Een rij die NIET op een tijdlijn-id staat maar wel op een tijdlijn-url.
-  const wees = `
-    FROM ap_my_reactions r JOIN ap_timeline t ON t.slug = r.site_slug AND t.url = r.target_uri
-     WHERE NOT EXISTS (SELECT 1 FROM ap_timeline t2 WHERE t2.slug = r.site_slug AND t2.id = r.target_uri)`;
-  const scheef = (kind, kolom) => `
-    FROM ap_timeline t
-     WHERE t.${kolom} = 1
-       AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
-                        WHERE r.site_slug = t.slug AND r.target_uri = t.id AND r.kind = '${kind}')`;
-  // 3. De derde bron: wat JIJ deed met een reactie onder je eigen post. De slug
-  //    hangt hier niet aan de rij maar aan de post; vandaar de twee joins. Een
-  //    rij zonder object_uri kan nooit een reactie dragen (fedi-react eist hem),
-  //    dus die uitsluiting verliest per constructie niets.
-  const acted = (kind, kolom) => `
-    FROM ap_interactions i
-     JOIN posts p ON p.id = i.post_id
-     JOIN sites s ON s.id = p.site_id
-     WHERE i.${kolom} = 1 AND IFNULL(i.object_uri, '') <> ''
-       AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
-                        WHERE r.site_slug = s.slug AND r.target_uri = i.object_uri AND r.kind = '${kind}')`;
-
-  if (opts.dryRun) {
-    const tel = (sql) => { try { return db.prepare(`SELECT COUNT(*) AS n ${sql}`).get().n; } catch { return 0; } };
-    uit.hersleuteld = tel(wees);
-    uit.aangevuld = tel(scheef('like', 'liked')) + tel(scheef('boost', 'boosted'));
-    uit.reacties = tel(acted('like', 'acted_like')) + tel(acted('boost', 'acted_boost'));
-    return uit;
-  }
-
-  try {
-    db.transaction(() => {
-      // 1. Hersleutelen: eerst de canonieke variant erbij, dan de permalink weg.
-      //    In die volgorde, zodat een onderbreking hooguit een dubbele rij
-      //    oplevert en nooit een verdwenen reactie.
-      uit.hersleuteld = db.prepare(`
-        INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind, created_at)
-        SELECT r.site_slug, t.id, r.kind, r.created_at ${wees}`).run().changes;
-      db.prepare(`DELETE FROM ap_my_reactions WHERE rowid IN (SELECT r.rowid ${wees})`).run();
-
-      // 2. Aanvullen vanuit de kolommen.
-      for (const [kind, kolom] of [['like', 'liked'], ['boost', 'boosted']]) {
-        uit.aangevuld += db.prepare(`
-          INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind)
-          SELECT t.slug, t.id, '${kind}' ${scheef(kind, kolom)}`).run().changes;
-      }
-
-      // 3. En vanuit acted_* op de reacties onder je eigen posts.
-      for (const [kind, kolom] of [['like', 'acted_like'], ['boost', 'acted_boost']]) {
-        uit.reacties += db.prepare(`
-          INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind)
-          SELECT s.slug, i.object_uri, '${kind}' ${acted(kind, kolom)}`).run().changes;
-      }
-    })();
-    if (uit.hersleuteld || uit.aangevuld || uit.reacties) {
-      console.log(`[AP] reaction migration v${REACTIONS_MIGRATION_VERSION}: ${uit.hersleuteld} re-keyed, ${uit.aangevuld} backfilled, ${uit.reacties} from comments`);
-    }
-    if (!opts.force) {
-      db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)')
-        .run('reactions_migration_version', String(REACTIONS_MIGRATION_VERSION));
-    }
-  } catch (e) {
-    // Niet fataal: de kolommen staan er nog, dus de oude waarheid is niet weg.
-    // Een volgende boot probeert het opnieuw, want de versie is niet gezet.
-    console.warn('[AP] reaction migration failed:', e.message);
-  }
-  return uit;
-}
-
-/**
- * Van wat de client stuurde naar de canonieke sleutel voor een reactie.
- *
- * Een post heeft twee URI's: zijn AP-object-id (.../ap/notes/<uuid>) en zijn
- * leesbare permalink (.../effortlesseffect). De Krant en het C2S-pad spreken de
- * eerste, de interact-pagina de tweede. Werden reacties onder allebei opgeslagen,
- * dan bestond dezelfde like twee keer -- en erger: een like uit de Krant was op
- * de interact-pagina onzichtbaar, want daar werd op de permalink gezocht.
- *
- * Dit was de naad die fase 1 bewust open liet ("samentrekken is werk voor fase
- * 2"). Robin liep er meteen tegenaan: een geboost en geliket bericht toonde geen
- * highlight. Vandaar hier, en niet later.
- *
- * De object-URI wint, want dat is waar ap_timeline op sleutelt en waar de
- * backfill op is gebaseerd. Kennen we de post niet, dan blijft de invoer staan:
- * een reactie op iets buiten je tijdlijn moet gewoon werken.
- */
-export function canonicalReactionUri(slug, uri) {
-  if (!slug || !uri) return uri;
-  try {
-    if (db.prepare('SELECT 1 FROM ap_timeline WHERE slug = ? AND id = ?').get(slug, uri)) return uri;
-    const row = db.prepare('SELECT id FROM ap_timeline WHERE slug = ? AND url = ? LIMIT 1').get(slug, uri);
-    return (row && row.id) || uri;
-  } catch { return uri; }
-}
-
-/**
- * Wat heb IK met dit object gedaan? Leest de tussentabel, de bron van waarheid
- * sinds shaer-9e9 fase 2. Vervangt getMyReactions en getTimelineReaction, die
- * dezelfde vraag beantwoordden uit twee verschillende bronnen.
- */
-export function getReaction(slug, uri) {
-  try {
-    const key = canonicalReactionUri(slug, uri);
-    const rows = (slug && key)
-      ? db.prepare('SELECT kind FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ?').all(slug, key)
-      : [];
-    return { liked: rows.some((r) => r.kind === 'like'), boosted: rows.some((r) => r.kind === 'boost') };
-  } catch { return { liked: false, boosted: false }; }
-}
-
-/**
- * Dezelfde vraag voor een hele pagina in EEN query. De C2S-tijdlijn zet
- * shaer:liked op elke post; per rij vragen zou dat een N+1 maken, en dan had je
- * een consistentiebug geruild voor een traagheidsbug.
- */
-export function getReactionsFor(slug, uris) {
-  const out = new Map();
-  const list = [...new Set((uris || []).filter(Boolean))].slice(0, 500);
-  if (!slug || !list.length) return out;
-  try {
-    const rows = db.prepare(
-      `SELECT target_uri, kind FROM ap_my_reactions
-        WHERE site_slug = ? AND target_uri IN (${list.map(() => '?').join(',')})`,
-    ).all(slug, ...list);
-    for (const r of rows) {
-      const cur = out.get(r.target_uri) || { liked: false, boosted: false };
-      if (r.kind === 'like') cur.liked = true;
-      if (r.kind === 'boost') cur.boosted = true;
-      out.set(r.target_uri, cur);
-    }
-  } catch { /* leeg = niets gereageerd, en dat is een veilige uitkomst */ }
-  return out;
-}
-
-export function setReaction(slug, uri, kind, on, opts = {}) {
-  if (!slug || !uri || (kind !== 'like' && kind !== 'boost')) return;
-  // Ook hier, en niet alleen bij sendInteraction. Deze functie schrijft ALLEEN de
-  // lokale vlag; het versturen gebeurt elders. Zonder deze poort zou je op een
-  // verhuisd account een like zien staan die nooit de deur uit is gegaan, en dat
-  // is de halve toestand die erger is dan een duidelijke weigering.
-  try {
-    const s = db.prepare('SELECT moved_to FROM sites WHERE slug = ?').get(slug);
-    if (movedLock(s).locked) { console.warn('[AP] reactie geweigerd, account verhuisd:', slug, kind); return; }
-  } catch { /* geen sites-tabel = geen verhuizing */ }
-  // EEN sleutel voor beide bronnen. opts.flagUri is de opgeloste object-URI van
-  // de aanroeper (het C2S-pad kent die uit resolveRemoteNote en dat is
-  // betrouwbaarder dan onze cache); anders leiden we hem af. Vroeger kreeg de
-  // tussentabel de URI die de client stuurde en de vlag de opgeloste -- dat
-  // maakte dezelfde like onvindbaar vanaf de andere pagina.
-  const flagUri = opts.flagUri || canonicalReactionUri(slug, uri);
-  setMyReaction(slug, flagUri, kind, !!on);
-  if (kind === 'boost') {
-    if (!on) unmarkBoosted(slug, flagUri);
-    else if (opts.note) upsertBoostedNote(slug, opts.note);
-    else markBoosted(slug, flagUri);
-  } else if (on) markLiked(slug, flagUri);
-  else unmarkLiked(slug, flagUri);
-}
-
-export function getTimelineReaction(slug, noteId) {
-  try { const r = db.prepare('SELECT liked, boosted FROM ap_timeline WHERE slug = ? AND id = ?').get(slug, noteId); return { liked: !!(r && r.liked), boosted: !!(r && r.boosted) }; } catch { return { liked: false, boosted: false }; }
-}
-// Boost a REMOTE post that may not be in your timeline (you don't follow the author):
-// store it in ap_timeline (INSERT OR IGNORE → no dup for followed posts) so it shows in
-// the Cirkel with a Boost badge, then flag it boosted.
-export function upsertBoostedNote(slug, note) {
-  if (!slug || !note || !note.object_uri) return;
-  const id = note.object_uri;
-  // Prefer the full typed media (incl. video/mp4 — a Loops boost is video-only and
-  // rendered a bare text tile); fall back to the image-only list for older callers.
-  const media = (note.media && note.media !== '[]')
-    ? note.media
-    : JSON.stringify((note.images || []).map((u) => ({ url: u, type: 'image/jpeg' })));
-  try {
-    const r = tlStmts().ins.run(id, slug, note.actor_uri || '', note.actor_name || '', note.actor_handle || '',
-      note.actor_icon || '', note.actor_url || '', note.content || '', note.url || null,
-      new Date().toISOString(), media, note.sensitive ? 1 : 0, note.cw || null);
-    if (!r.changes) {
-      // Row already cached (INSERT OR IGNORE) → refresh it with the freshly
-      // resolved note. Without this a row cached without its cover (or with
-      // stale content) stayed stale forever — even boosting again didn't heal it.
-      // Keep the CACHED media when the resolve yielded none: an empty re-resolve
-      // used to clobber a good media_json (the followed copy had the video, the
-      // boost wiped it to []).
-      db.prepare(`UPDATE ap_timeline SET content = ?, media_json = CASE WHEN ? = '[]' THEN media_json ELSE ? END,
-                  nsfw = ?, cw = ?, url = COALESCE(?, url) WHERE slug = ? AND id = ?`)
-        .run(note.content || '', media, media, note.sensitive ? 1 : 0, note.cw || null, note.url || null, slug, id);
-    }
-  } catch { /* ignore */ }
-  markBoosted(slug, id);
-}
-export function boostedCount(slug) {
-  // Geboost EN in je tijdlijn, zoals voorheen: de tussentabel kan ook een boost
-  // bevatten van iets dat er (nog) niet in staat.
-  try {
-    if (!_boostedCount) _boostedCount = db.prepare(`SELECT COUNT(*) AS n FROM ap_my_reactions r
-      JOIN ap_timeline t ON t.slug = r.site_slug AND t.id = r.target_uri
-      WHERE r.site_slug = ? AND r.kind = 'boost'`);
-    return _boostedCount.get(slug).n;
-  } catch { return 0; }
-}
Index: src/services/ap-timeline.js
===================================================================
--- src/services/ap-timeline.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,583 +1,0 @@
-/**
- * ap-timeline.js — de leeskant van de fediverse-tijdlijn (stap 5 van shaer-drc).
- *
- * Alles wat een client of route uit ap_timeline en de gesprekken LEEST:
- * de tijdlijn zelf, de feed-cursor met long-poll, de gesprekslijsten en
- * leesmarkeringen, en de serialisatiehulpen (bijlagen, emoji, object-links,
- * citaten) die een rij naar de C2S-vorm vertalen.
- *
- * De SCHRIJFKANT blijft waar hij was: de inbox, de backfill en self-heal
- * schrijven via tlStmts, dat hierom mee-exporteert. Een module importeert
- * nooit uit ActivityPubService; de ene uitzondering op "alleen omlaag" --
- * getReactionsFor, uit het reactiecluster -- komt daarom binnen via
- * wireTimeline, hetzelfde injectiepatroon als guardianship en ap-c2s.
- */
-import db, { isoSql, NU_ISO } from '../config/database.js';
-
-// De helper woont sinds shaer-a937 in config/database.js: elke plek die
-// sorteert had hem nodig, en twee kopieen van dezelfde regel lopen uit elkaar.
-// Bovenaan, want de eerste statements hieronder gebruiken hem al.
-const STEMPEL = isoSql;
-
-// Het ene werktuig uit de dienstlaag. ActivityPubService vult het onderaan
-// zijn eigen evaluatie; een aanroep voor de koppeling is een programmeerfout.
-let getReactionsFor;
-export function wireTimeline(deps) {
-  ({ getReactionsFor } = deps);
-}
-
-let _insTl, _listTl, _delTl;
-export function tlStmts() {
-  if (!_insTl) {
-    _insTl = db.prepare(`INSERT OR IGNORE INTO ap_timeline (id, slug, author_uri, author_name, author_handle, author_icon, author_url, content, url, published, media_json, nsfw, cw, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,${NU_ISO})`);
-    _listTl = db.prepare(`SELECT * FROM ap_timeline WHERE slug = ? ORDER BY ${STEMPEL('COALESCE(published, created_at)')} DESC LIMIT ? OFFSET ?`);
-    _delTl = db.prepare('DELETE FROM ap_timeline WHERE id = ?');
-  }
-  return { ins: _insTl, list: _listTl, del: _delTl };
-}
-/**
- * De tijdlijn, met liked/boosted uit de TUSSENTABEL (shaer-9e9).
- *
- * De rijen komen met SELECT *, dus ap_timeline.liked en .boosted liften mee --
- * en die zijn sinds fase 1 nog maar een afgeleide. De Krant tekende zijn
- * knoppen daar wel op, terwijl de toggle al uit getReaction besliste: tekenen en
- * beslissen leunden dus op verschillende bronnen. Ze waren het eens zolang de
- * migratie ze gelijk hield, maar dat was synchronisatie en geen ontwerp.
- *
- * Bewust in JS en niet als join: met SELECT * zouden twee kolommen `liked`
- * heten en hangt het van de driver af welke wint. Eén extra query per pagina
- * (dezelfde batch die de C2S-tijdlijn gebruikt) is dat niet waard.
- */
-export function getTimeline(slug, limit, offset) {
-  const rows = tlStmts().list.all(slug, limit || 50, offset || 0);
-  const reacties = getReactionsFor(slug, rows.map((r) => r.id));
-  for (const r of rows) {
-    const x = reacties.get(r.id);
-    r.liked = !!(x && x.liked);
-    r.boosted = !!(x && x.boosted);
-  }
-  return rows;
-}
-
-/**
- * The direct notes addressed to this account: a plain DM, a guardian's wave
- * (§5), a ward's 🛟 help request (§5.2.1). They live in ap_mentions and NOT in
- * the timeline, because a note addressed to named people is a message and not a
- * post (belongsInTimeline).
- *
- * A client that only reads the timeline therefore sees none of them, which is
- * exactly what happened to Shaer: Berichten showed your own replies (those come
- * from your outbox) and nothing that was said to you. The C2S inbox read serves
- * both, so the app has one door for everything that arrives.
- *
- * A public mention from someone you follow is stored in both tables; those are
- * skipped here and stay a post.
- */
-// Inbound replies on YOUR posts, for the app's message stream. They live in
-// ap_interactions (the web's comment machinery) and deliberately NOT in
-// ap_mentions (the mention store returns early for replies-to-us), so the
-// C2S read missed them entirely: a reply arrived at the other side
-// everywhere EXCEPT in the other's app (Robins melding, 30-7: "komt niet
-// binnen bij de ander").
-const REPLY_COLUMNS = `
-      i.object_uri, i.actor_uri, i.actor_name, i.actor_handle, i.actor_icon, i.actor_url,
-      i.content, i.published, i.created_at, i.parent_uri, i.post_id,
-      i.emoji_json, i.actor_emoji_json, i.media_json, i.quote_json, i.embed_json`;
-
-/** Dezelfde antwoordrijen, maar op object-uri -- voor de verschil-lezing. */
-export function replyRowsByUri(slug, uris) {
-  const list = (uris || []).filter((u) => typeof u === 'string' && u);
-  if (!list.length) return [];
-  try {
-    const holes = list.map(() => '?').join(',');
-    return db.prepare(`SELECT ${REPLY_COLUMNS} FROM ap_interactions i
-                        JOIN posts p ON p.id = i.post_id
-                        JOIN sites s ON s.id = p.site_id
-                       WHERE s.slug = ? AND i.kind = 'reply' AND i.object_uri IN (${holes})`)
-      .all(slug, ...list);
-  } catch { return []; }
-}
-
-/** Tijdlijnrijen op id, met dezelfde afgeleide liked/boosted als getTimeline. */
-export function timelineRowsByIds(slug, ids) {
-  const list = (ids || []).filter((u) => typeof u === 'string' && u);
-  if (!list.length) return [];
-  try {
-    const holes = list.map(() => '?').join(',');
-    const rows = db.prepare(`SELECT * FROM ap_timeline WHERE slug = ? AND id IN (${holes})`).all(slug, ...list);
-    const reacties = getReactionsFor(slug, rows.map((r) => r.id));
-    for (const r of rows) {
-      const x = reacties.get(r.id);
-      r.liked = !!(x && x.liked);
-      r.boosted = !!(x && x.boosted);
-    }
-    return rows;
-  } catch { return []; }
-}
-
-export function getReplyMessages(slug, limit) {
-  try {
-    return db.prepare(`
-      SELECT ${REPLY_COLUMNS}
-      FROM ap_interactions i
-      JOIN posts p ON p.id = i.post_id
-      JOIN sites s ON s.id = p.site_id
-      WHERE s.slug = ? AND i.kind = 'reply'
-      ORDER BY ${STEMPEL('COALESCE(i.published, i.created_at)')} DESC LIMIT ?`).all(slug, limit || 60);
-  } catch { return []; }
-}
-
-/**
- * Een merk voor "is er iets veranderd aan wat de inbox-lezing zou opleveren?"
- * (shaer-n05).
- *
- * Alle VIER de poten die de inbox samenvoegt tellen mee -- tijdlijn, berichten,
- * antwoorden op je eigen posts, en wat je zelf verstuurde. Zou er een ontbreken,
- * dan blijft een wachtende client slapen terwijl er wel degelijk iets is
- * bijgekomen, en dat is erger dan niet wachten: het lijkt te werken.
- *
- * rowid en niet een tijdstempel: rowid loopt strikt op per invoeging, terwijl
- * twee dingen in dezelfde seconde kunnen aankomen en een `published` van een
- * andere server niet te vertrouwen is.
- *
- * Ondoorzichtig voor de client. Hij krijgt hem terug en geeft hem ongewijzigd
- * mee; de vorm mag veranderen zonder dat dat iets breekt.
- */
-export function feedCursor(slug) {
-  try {
-    const r = db.prepare('SELECT MAX(rev) AS n FROM ap_feed_state WHERE slug = ?').get(slug);
-    return String((r && r.n) || 0);
-  } catch { return '0'; }
-}
-
-/**
- * Wat er sinds `rev` met deze tijdlijn gebeurd is: welke berichten er nieuw zijn,
- * bewerkt, of weg.
- *
- * Nog niet gebruikt door een leespad -- de vorm van de aankomst is shaer-of7 en
- * de "bewerkt"-markering is daar nog een open beslissing. Maar de gegevens
- * ontstaan hoe dan ook bij het bijhouden van de merksteen, en dit is de enige
- * plek waar ze samen te lezen zijn.
- */
-export function feedChangesSince(slug, rev, limit = 200) {
-  try {
-    return db.prepare(`SELECT object_uri, kind, rev FROM ap_feed_state
-                        WHERE slug = ? AND rev > ? ORDER BY rev ASC LIMIT ?`)
-      .all(slug, parseInt(rev, 10) || 0, limit);
-  } catch { return []; }
-}
-
-// Zoveel clients mogen er tegelijk op EEN account staan wachten. Een client met
-// een kapotte herverbind-lus mag de instance niet vastzetten; de overtolligen
-// krijgen gewoon meteen antwoord in plaats van een fout.
-const FEED_WAIT_MAX = 4;
-const _wachters = new Map();
-
-/**
- * Wacht tot de inbox-lezing iets anders zou opleveren dan bij `since`.
- *
- * Bewust met een interne tik en niet met een gebeurtenis-emitter. Een emitter
- * moet op ELKE plek worden aangeroepen waar er iets bijkomt, en de plek die je
- * vergeet is precies de melding die nooit aankomt. Twee tot vier MAX(rowid)-
- * queries per seconde is niets, en dit kan niets missen. Prijs: hooguit een tik
- * vertraging.
- */
-export async function waitForFeedChange(slug, opts = {}) {
-  const tickMs = Math.max(50, opts.tickMs || 1000);
-  const waitMs = Math.max(0, opts.waitMs || 0);
-  const since = String(opts.since || '');
-  let cursor = feedCursor(slug);
-  // Geen sinds, al iets veranderd, of niet willen wachten: meteen antwoorden.
-  if (!since || since !== cursor || !waitMs) return { cursor, changed: !!since && since !== cursor, waited: false };
-
-  const bezet = _wachters.get(slug) || 0;
-  if (bezet >= FEED_WAIT_MAX) return { cursor, changed: false, waited: false, busy: true };
-  _wachters.set(slug, bezet + 1);
-  try {
-    const einde = Date.now() + waitMs;
-    while (Date.now() < einde) {
-      if (opts.signal && opts.signal.aborted) break;   // client hing op
-      const rest = Math.min(tickMs, einde - Date.now());
-      await new Promise((r) => setTimeout(r, rest));
-      cursor = feedCursor(slug);
-      if (cursor !== since) return { cursor, changed: true, waited: true };
-    }
-    return { cursor, changed: false, waited: true };
-  } finally {
-    const n = (_wachters.get(slug) || 1) - 1;
-    if (n > 0) _wachters.set(slug, n); else _wachters.delete(slug);
-  }
-}
-
-// ── Gesprekken: eerst wie, dan pas wat (shaer-frontend-yso) ──────────
-//
-// De oude lezing gaf de nieuwste 60 berichten over ALLE gesprekken samen. Dat
-// knipt geschiedenis weg zonder dat iemand het merkt, en het is bij DM's veel
-// erger dan bij posts: dat zijn er meer en het zijn kortere berichten, dus een
-// druk gesprek kan de 60 in zijn eentje opeten en de rest uit de lezing duwen.
-// Viel het laatste bericht van iemand erbuiten, dan verdween die persoon
-// helemaal uit Messages -- de avatarhemel plaatst mensen op de leeftijd van hun
-// laatste bericht, dus geen bericht is geen gezicht.
-//
-// Vandaar twee lezingen. Deze geeft EEN rij per tegenpartij, hoe druk iemand
-// ook is, en conversationHistory hieronder geeft het gesprek zelf met een
-// cursor. Wat de client van de hemel nodig heeft -- wie, wanneer, en waarmee --
-// zit in die ene nieuwste note.
-//
-// Een gesprek is hier hetzelfde als in de app: incoming zijn de ap_mentions
-// (die tabel IS de aan ons gerichte post), uitgaand zijn de eigen notes met
-// visibility 'direct'. Een publiek antwoord is geen gesprek en hoort niet als
-// gezicht in de hemel.
-/**
- * EEN STEMPEL IN EEN VORM, en dat is hier geen netheid maar de volgorde zelf.
- *
- * Drie vormen kwamen samen in deze unie: `2026-08-13 19:26:17` van SQLite's
- * CURRENT_TIMESTAMP, `2026-08-13T18:21:57Z` uit een object, en dezelfde met
- * milliseconden. Als TEKST vergeleken staat op plek 10 een spatie tegen een
- * T -- en een spatie is kleiner. Dus sorteerde binnen dezelfde dag alles wat
- * JIJ stuurde vóór alles wat binnenkwam, ongeacht de klok (Barts melding 14-8:
- * een bericht van 00:30 stond boven een antwoord van 20:22 de avond ervoor).
- *
- * strftime leest alle drie en geeft er een vorm voor terug, in UTC. Lukt het
- * niet, dan blijft de rauwe waarde staan -- dan is die ene rij verkeerd
- * gesorteerd in plaats van de hele lijst.
- *
- * Dit gaat ook de client aan: `new Date('2026-08-13 19:26:17')` leest in
- * JavaScript als LOKALE tijd en `...T19:26:17Z` als UTC. Dezelfde rij gaf dus
- * een leeftijd die twee uur verschilde per vorm.
- */
-
-const CONVERSATION_UNION = `
-  SELECT m.actor_uri AS other, ${STEMPEL('COALESCE(m.published, m.created_at)')} AS stamp,
-         'in' AS direction, m.object_uri AS ref
-    FROM ap_mentions m
-   WHERE m.slug = @slug AND m.actor_uri IS NOT NULL AND m.actor_uri <> ''
-  UNION ALL
-  SELECT j.value AS other, ${STEMPEL('o.created_at')} AS stamp,
-         'out' AS direction, o.id AS ref
-    FROM ap_outbox o
-    JOIN json_each(COALESCE(NULLIF(o.to_actors, ''), json_array(o.to_actor))) j
-   WHERE o.site_slug = @slug AND o.visibility = 'direct'
-     AND j.value IS NOT NULL AND j.value <> ''`;
-
-/**
- * Een rij per tegenpartij: zijn nieuwste bericht, nieuwste gesprek eerst.
- *
- * Compleet van vorm -- het aantal rijen is het aantal mensen, niet het aantal
- * berichten -- dus de hemel kan niemand meer kwijtraken doordat een ander druk
- * was. Zonder limiet, en dat mag: dit schaalt met je kring.
- */
-export function conversationHeads(slug) {
-  try {
-    // Twee rijen per persoon, niet een: het nieuwste bericht (dat bepaalt waar
-    // iemand in de hemel hangt) EN het nieuwste bericht VAN HEM.
-    //
-    // Die tweede is er omdat het nieuwste bericht van jou kan zijn, en dan
-    // draagt het jouw byline. De hemel zoekt de naam en het gezicht van de
-    // ander in een bericht van de ander -- vond hij dat niet, dan viel hij
-    // terug op het staartje van de actor-uri en heette tante opeens
-    // 'hotelbreakfast'. Op het toestel gezien, 10-8.
-    //
-    // Valt het samen (het nieuwste is al van hem), dan is het een rij; dubbel
-    // sturen doen we niet.
-    return db.prepare(`
-      SELECT other, stamp, direction, ref FROM (
-        SELECT *, ROW_NUMBER() OVER (PARTITION BY other ORDER BY stamp DESC, ref DESC) AS rn
-          FROM (${CONVERSATION_UNION})
-      ) WHERE rn = 1
-      UNION
-      SELECT other, stamp, direction, ref FROM (
-        SELECT *, ROW_NUMBER() OVER (PARTITION BY other ORDER BY stamp DESC, ref DESC) AS rn
-          FROM (${CONVERSATION_UNION}) WHERE direction = 'in'
-      ) WHERE rn = 1
-      ORDER BY stamp DESC, ref DESC`).all({ slug });
-  } catch { return []; }
-}
-
-/**
- * Een gesprek, nieuwste eerst, met een cursor.
- *
- * BEIDE KANTEN ONDER EEN LIMIET. In de oude lezing werden jouw kant
- * (getSentNotes) en hun kant apart afgekapt, waardoor een gesprek eenzijdig
- * kon lijken -- alsof iemand nooit geantwoord had. Hier is de limiet er een
- * voor het gesprek als geheel.
- *
- * `before` is de cursor van het OUDSTE bericht dat je al hebt; je krijgt wat
- * daarvoor ligt. Er komt er een extra op om te weten of er nog meer is: de
- * client hoort dat te weten zonder te moeten gokken, en zonder dat weten kan
- * 'load more' niet eerlijk verschijnen.
- *
- * DE CURSOR IS SAMENGESTELD -- '<stempel>|<ref>' -- en niet alleen de stempel.
- * Twee berichten in dezelfde seconde is bij DM's geen randgeval maar een
- * gesprek, en met 'stamp < before' zou alles wat die grensseconde deelt stil
- * overgeslagen worden. Je zou het niet merken: de pagina komt gewoon, er
- * ontbreekt alleen iets in het midden.
- */
-const cursorOf = (r) => (r ? `${r.stamp}|${r.ref}` : null);
-
-export function conversationHistory(slug, other, { before = null, limit = 60 } = {}) {
-  try {
-    const n = Math.min(Math.max(parseInt(limit, 10) || 60, 1), 200);
-    const sep = String(before || '').indexOf('|');
-    const bStamp = before && sep > 0 ? String(before).slice(0, sep) : null;
-    const bRef = before && sep > 0 ? String(before).slice(sep + 1) : null;
-    const rows = db.prepare(`
-      SELECT other, stamp, direction, ref FROM (${CONVERSATION_UNION})
-       WHERE other = @other
-         AND (@bStamp IS NULL OR stamp < @bStamp OR (stamp = @bStamp AND ref < @bRef))
-       ORDER BY stamp DESC, ref DESC LIMIT @n`).all({ slug, other, bStamp, bRef, n: n + 1 });
-    const more = rows.length > n;
-    const page = more ? rows.slice(0, n) : rows;
-    return { rows: page, more, oldest: cursorOf(page[page.length - 1]) };
-  } catch { return { rows: [], more: false, oldest: null }; }
-}
-
-// De kolommen die een bericht tot kaart maken. Een constante, want de
-// gesprekslezing haalt dezelfde rows op: twee lijsten die uiteenlopen leveren
-// een kaart die op de ene plek een plaatje heeft en op de andere niet.
-const MESSAGE_COLUMNS = `
-      m.object_uri, m.note_url, m.actor_uri, m.actor_name, m.actor_handle, m.actor_icon, m.actor_url,
-      m.content, m.published, m.created_at, m.wave, m.help_request, m.in_reply_to,
-      m.emoji_json, m.actor_emoji_json, m.media_json, m.quote_json, m.embed_json`;
-
-/** Dezelfde berichtrijen, maar op object-uri -- voor een gesprek. */
-export function messageRowsByUri(slug, uris) {
-  const lijst = (uris || []).filter((u) => typeof u === 'string' && u);
-  if (!lijst.length) return [];
-  try {
-    const gaten = lijst.map(() => '?').join(',');
-    return db.prepare(`SELECT ${MESSAGE_COLUMNS} FROM ap_mentions m
-                        WHERE m.slug = ? AND m.object_uri IN (${gaten})`).all(slug, ...lijst);
-  } catch { return []; }
-}
-
-/**
- * Tot waar deze lezer elk gesprek gelezen heeft (shaer-frontend-3tx).
- *
- * De markering komt uit AS2 `Read`-activiteiten, en die zijn OPTELLEND: het
- * lezen van bericht N maakt niets anders ongelezen. Daarom is achteruit gaan
- * geen regel die iemand moet onthouden maar een eigenschap van het model --
- * markRead neemt het maximum. Een 'zet mijn markering op X' zou een toestel
- * dat een week uit stond je gelezen berichten weer op ongelezen laten zetten.
- */
-export function readMarkers(slug) {
-  try {
-    return new Map(db.prepare('SELECT other, cursor FROM ap_read_markers WHERE slug = ?')
-      .all(slug).map((r) => [r.other, r.cursor]));
-  } catch { return new Map(); }
-}
-
-/**
- * Markeer een gesprek als gelezen tot en met dit bericht.
- *
- * Het object van de Read is een berichturi; welk gesprek dat is en waar het in
- * de tijd staat weet de server zelf, dus de client hoeft niets uit te rekenen
- * en kan er ook niet naast zitten.
- */
-export function markRead(slug, objectUri) {
-  try {
-    const rij = db.prepare(`SELECT other, stamp, ref FROM (${CONVERSATION_UNION})
-                             WHERE ref = @ref ORDER BY stamp DESC LIMIT 1`)
-      .get({ slug, ref: String(objectUri || '') });
-    if (!rij) return null;
-    const cursor = `${rij.stamp}|${rij.ref}`;
-    db.prepare(`INSERT INTO ap_read_markers (slug, other, cursor) VALUES (?,?,?)
-                ON CONFLICT(slug, other) DO UPDATE SET cursor = MAX(cursor, excluded.cursor), at = CURRENT_TIMESTAMP`)
-      .run(slug, rij.other, cursor);
-    return { other: rij.other, cursor };
-  } catch { return null; }
-}
-
-/**
- * Hoeveel er per gesprek nog ongelezen is, en of daar een zwaai bij zit.
- *
- * Een COUNT en geen bijgehouden getal (Barts besluit): niets om op te hogen
- * bij bezorging, niets om te verlagen bij lezen, en bij een verwijdering klopt
- * het vanzelf weer.
- *
- * Een zwaai telt apart, want dat is geen gesprek maar een zetje van een
- * guardian -- die hoort een eigen teken te krijgen en niet opgeteld te worden.
- * Eigen berichten tellen nooit mee: je hebt jezelf gelezen.
- */
-export function unreadPerConversation(slug, { messagesAllowed = true, guardians = new Set() } = {}) {
-  try {
-    // DE POORT TELT MEE. Staat messages dicht, dan toont de app die berichten
-    // niet -- en dan mag een badge ze ook niet aankondigen, want dat getal
-    // vertelt precies wat de poort verbergt. Wat er altijd door mag telt wel:
-    // het guardian-kanaal en de boei. Zelfde regel als bij de serialisatie.
-    const rijen = db.prepare(`
-      SELECT u.other AS other,
-             COUNT(*) AS n,
-             MAX(CASE WHEN m.wave = 1 THEN 1 ELSE 0 END) AS wave
-        FROM (${CONVERSATION_UNION}) u
-        LEFT JOIN ap_read_markers r ON r.slug = @slug AND r.other = u.other
-        LEFT JOIN ap_mentions m ON m.slug = @slug AND m.object_uri = u.ref
-       WHERE u.direction = 'in'
-         AND (r.cursor IS NULL OR (u.stamp || '|' || u.ref) > r.cursor)
-         AND (@open = 1 OR m.help_request = 1 OR u.other IN (SELECT value FROM json_each(@guardians)))
-       GROUP BY u.other`)
-      .all({ slug, open: messagesAllowed ? 1 : 0, guardians: JSON.stringify([...guardians]) });
-    return new Map(rijen.map((r) => [r.other, { n: r.n, wave: !!r.wave }]));
-  } catch { return new Map(); }
-}
-
-export function getDirectMessages(slug, limit) {
-  try {
-    return db.prepare(`
-      SELECT ${MESSAGE_COLUMNS}
-      FROM ap_mentions m
-      WHERE m.slug = ?
-        AND NOT EXISTS (SELECT 1 FROM ap_timeline t WHERE t.slug = m.slug AND t.id = m.object_uri)
-      ORDER BY ${STEMPEL('COALESCE(m.published, m.created_at)')} DESC LIMIT ?`).all(slug, limit || 60);
-  } catch { return []; }
-}
-
-/**
- * A stored stamp as an ISO instant. SQLite's CURRENT_TIMESTAMP writes
- * 'YYYY-MM-DD HH:MM:SS' in UTC, which Date.parse reads as LOCAL time; on a
- * server two hours ahead that dated every message two hours early and put the
- * conversation in the wrong order. A `published` from the wire is already ISO
- * and passes through untouched.
- */
-export function isoStamp(v) {
-  if (!v) return undefined;
-  const s = String(v);
-  if (/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}$/.test(s)) return `${s.replace(' ', 'T')}Z`;
-  const t = Date.parse(s);
-  return Number.isFinite(t) ? new Date(t).toISOString() : undefined;
-}
-
-// Inbox C2S read: a timeline row's media_json ([{url, type}], written on the
-// inbound Create) → AS2 `attachment` array, so a client (Shaer) can render a
-// friend's images/audio/video natively, exactly like own outbox posts. The
-// stored `type` is the mediaType and may be ''. Malformed JSON yields
-// undefined and never blocks the item.
-export function timelineAttachments(mediaJson) {
-  try {
-    const list = mediaJson ? JSON.parse(mediaJson) : [];
-    const rows = (Array.isArray(list) ? list : [])
-      .filter((m) => m && m.url)
-      .map((m) => {
-        const a = { type: 'Document', mediaType: m.type || undefined, url: m.url };
-        if (m.poster) a.icon = { type: 'Image', url: m.poster }; // the video's still (shaer-zowq)
-        return a;
-      });
-    return rows.length ? rows : undefined;
-  } catch { return undefined; }
-}
-
-// FEP-9098 custom emojis. Inbound: keep the note's Emoji tags (as JSON) so we
-// can serve them back. `extractEmojiTags` returns the JSON to store (or null);
-// `timelineEmojis` turns the stored JSON back into an AS2 `tag` array for the
-// C2S inbox read, so a client (Shaer) can render :shortcode: as an image.
-export function extractEmojiTags(tag) {
-  const arr = Array.isArray(tag) ? tag : (tag ? [tag] : []);
-  const emojis = arr.filter((t) => t && (Array.isArray(t.type) ? t.type[0] : t.type) === 'Emoji'
-    && typeof t.name === 'string' && t.icon);
-  return emojis.length ? JSON.stringify(emojis) : null;
-}
-// ── Gate-filters voor de C2S-serialisatie (shaer-ahy.1, 8-8) ──────
-//
-// Dezelfde regel als bij de embeds: de poort zit bij de AFLEVERING. Een
-// bijlage die de client alleen verbergt is wel degelijk geleverd, dus wat
-// dicht is wordt hier nooit geserialiseerd. Puur, zodat de regels los van de
-// routes te toetsen zijn.
-
-/** Bijlagen door de beeld- en muziekpoort. Leeg wordt undefined, zoals de
- *  serialisatie dat overal doet. */
-export function gateAttachments(atts, { images = true, audio = true } = {}) {
-  if (!Array.isArray(atts)) return atts;
-  const out = atts.filter((a) => {
-    const mt = String((a && a.mediaType) || '');
-    if (!images && mt.startsWith('image/')) return false;
-    if (!audio && (mt.startsWith('audio/') || (a && a.type === 'Audio'))) return false;
-    return true;
-  });
-  return out.length ? out : undefined;
-}
-
-/** Tag-array zonder de FEP-9098 Emoji-tags, voor een dichte emoji-poort. De
- *  :shortcode: blijft als tekst staan -- dat is eerlijk: er STAAT iets, het
- *  wordt alleen niet als plaatje van een vreemde server gerenderd. */
-export function stripEmojiTags(tags) {
-  if (!Array.isArray(tags)) return tags;
-  const out = tags.filter((t) => (Array.isArray(t && t.type) ? t.type[0] : (t && t.type)) !== 'Emoji');
-  return out.length ? out : undefined;
-}
-
-export function timelineEmojis(emojiJson) {
-  try { const arr = emojiJson ? JSON.parse(emojiJson) : null; return (Array.isArray(arr) && arr.length) ? arr : undefined; }
-  catch { return undefined; }
-}
-
-// FEP-e232 object links (quotes / inline references). Inbound: keep the note's
-// Link tags whose mediaType marks an AP object (the AS2-profiled ld+json, or
-// activity+json as its equivalent) as JSON, so the C2S inbox read can serve
-// them back and a client (Shaer) can render the quote/reference. Mirrors
-// extractEmojiTags. Plain hyperlinks (text/html) and Mentions are dropped.
-export function extractObjectLinkTags(tag) {
-  const arr = Array.isArray(tag) ? tag : (tag ? [tag] : []);
-  const links = arr.filter((t) => {
-    if (!t || (Array.isArray(t.type) ? t.type[0] : t.type) !== 'Link') return false;
-    if (typeof t.href !== 'string' || !t.href) return false;
-    const mt = String(t.mediaType || '').toLowerCase();
-    return (mt.startsWith('application/ld+json') && mt.includes('activitystreams'))
-      || mt.startsWith('application/activity+json');
-  });
-  return links.length ? JSON.stringify(links) : null;
-}
-export function timelineObjectLinks(linkJson) {
-  try { const arr = linkJson ? JSON.parse(linkJson) : null; return (Array.isArray(arr) && arr.length) ? arr : undefined; }
-  catch { return undefined; }
-}
-
-// FEP-044f quote posts: a quote is usually NOT an FEP-e232 tag but an
-// object-level property. FEP-044f §"how to recognise" lists them all:
-// `quote` (the FEP property, a string or an embedded Link/object), and the
-// de-facto `quoteUrl` (as:), `quoteUri` (fedibird), `_misskey_quote` (misskey).
-// This returns the quoted object's URL from whichever is present.
-export function extractQuoteUrl(note) {
-  if (!note || typeof note !== 'object') return null;
-  const q = note.quote ?? note.quoteUrl ?? note.quoteUri ?? note['_misskey_quote'];
-  if (!q) return null;
-  if (typeof q === 'string') return q || null;
-  if (typeof q === 'object') return (typeof q.id === 'string' && q.id) || (typeof q.href === 'string' && q.href) || null;
-  return null;
-}
-
-// The note's object-link tags for storage: real FEP-e232 Link tags PLUS any
-// FEP-044f object-level quote, normalised to one FEP-e232-shaped Link (rel
-// _misskey_quote) so the client's single object-link path renders them all.
-// Deduped by href. Returns the JSON to store (or null if the note has neither).
-export function extractLinkJson(note) {
-  const links = [];
-  const fromTag = extractObjectLinkTags(note && note.tag);
-  if (fromTag) { try { links.push(...JSON.parse(fromTag)); } catch { /* ignore */ } }
-  const qUrl = extractQuoteUrl(note);
-  if (qUrl && !links.some((l) => l && l.href === qUrl)) {
-    links.push({ type: 'Link', mediaType: 'application/activity+json', href: qUrl,
-      rel: ['https://misskey-hub.net/ns#_misskey_quote'], name: qUrl });
-  }
-  return links.length ? JSON.stringify(links) : null;
-}
-
-// The URL of the quoted post, from either an object-level quote (FEP-044f) or a
-// quote-rel FEP-e232 Link tag. Used to resolve the embedded quote card.
-export function quoteHrefOf(note) {
-  const direct = extractQuoteUrl(note);
-  if (direct) return direct;
-  const arr = Array.isArray(note && note.tag) ? note.tag : (note && note.tag ? [note.tag] : []);
-  for (const t of arr) {
-    if (!t || (Array.isArray(t.type) ? t.type[0] : t.type) !== 'Link' || typeof t.href !== 'string') continue;
-    const rel = Array.isArray(t.rel) ? t.rel : (t.rel ? [t.rel] : []);
-    if (rel.some((r) => /quote/i.test(String(r)))) return t.href;
-  }
-  return null;
-}
-
-// Turn the stored quote snapshot back into the object the C2S inbox read serves
-// as `shaer:quote`, so the client can render the embedded quote card.
-export function timelineQuote(quoteJson) {
-  try { const q = quoteJson ? JSON.parse(quoteJson) : null; return (q && typeof q === 'object') ? q : undefined; }
-  catch { return undefined; }
-}
Index: src/services/ap-transport.js
===================================================================
--- src/services/ap-transport.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,403 +1,0 @@
-/**
- * ap-transport.js — het transport onder de federatie (stap 3 van shaer-drc).
- *
- * Alles wat hier woont raakt het netwerk of de sleutels, en niets erin weet
- * iets van Notes, feeds of guardianship:
- *   - de SSRF-poort (safeFetch en zijn wachters) voor ELKE uitgaande fetch
- *   - de RSA-sleutels per actor
- *   - HTTP Signatures: tekenen (deliver, signedGetHeaders) en controleren
- *     (verifyRequest)
- *   - de bezorging met wachtrij en backoff (deliverWithRetry en de worker)
- *   - de ondertekende GET (signedGetJson) en zijn onbetekende broer (apGetJson)
- *
- * Verhuisd uit ActivityPubService.js, dat alles her-exporteert: bestaande
- * importeurs merken niets. De afhankelijkheden wijzen alleen omlaag (db,
- * ap-core, Node zelf) -- er mag hier nooit iets uit de dienstlaag bij.
- */
-import crypto from 'crypto';
-import fs from 'fs';
-import dns from 'dns';
-import net from 'net';
-import db from '../config/database.js';
-import { actorId } from './ap-core.js';
-
-// ── SSRF guard for outbound fetches ───────────────────────────────
-// Remote URLs (actor/keyId/webfinger/inbox/inReplyTo) are attacker-controlled, so
-// every outbound fetch must refuse hosts that resolve to private/loopback ranges
-// (cloud metadata, internal services) — on the initial host AND each redirect hop.
-function isBlockedIp(ip) {
-  if (!ip) return true;
-  const v = net.isIP(ip);
-  if (v === 4) {
-    const o = ip.split('.').map(Number);
-    return o[0] === 127 || o[0] === 10 || o[0] === 0
-      || (o[0] === 172 && o[1] >= 16 && o[1] <= 31)
-      || (o[0] === 192 && o[1] === 168)
-      || (o[0] === 169 && o[1] === 254)
-      || (o[0] === 100 && o[1] >= 64 && o[1] <= 127); // CGNAT
-  }
-  if (v === 6) {
-    const s = ip.toLowerCase().replace(/^\[|\]$/g, '');
-    return s === '::1' || s === '::' || s.startsWith('fc') || s.startsWith('fd') || s.startsWith('fe80')
-      || s.startsWith('::ffff:127.') || s.startsWith('::ffff:10.') || s.startsWith('::ffff:192.168.')
-      || s.startsWith('::ffff:169.254.') || s.startsWith('::ffff:172.');
-  }
-  return true; // not an IP literal we recognise → refuse
-}
-/**
- * Uitzonderingen op de SSRF-poort, voor een testkudde op de eigen machine
- * (shaer-6wt: honderd wards met een guardian, Barts opdracht 8-8).
- *
- * WAAROM DIT MAG BESTAAN. De bescherming hierboven is er omdat een actor-URI van
- * een VREEMDE komt: een aanvaller die "http://169.254.169.254/" doorgeeft laat
- * ons zijn werk doen. Deze lijst gaat niet over vreemden -- hij staat in de
- * omgeving van deze server, wordt door de beheerder gezet, en is leeg tenzij
- * iemand hem expliciet vult.
- *
- * WAAROM HIJ ZO SMAL IS. Geen vlag die "loopback is oke" zegt, maar een lijst
- * van precieze host:poort-paren. `[::1]:3060` opent niet 127.0.0.1, niet poort
- * 3061, en niets in het interne netwerk. Een brede vlag zou de bescherming in
- * een dev-omgeving uitzetten, en dev-omgevingen worden productie.
- *
- *   AP_ALLOW_HOSTS="[::1]:3060,[::1]:3061"
- */
-const AP_ALLOW_HOSTS = new Set(
-  String(process.env.AP_ALLOW_HOSTS || '').split(',').map((x) => x.trim().toLowerCase()).filter(Boolean),
-);
-function isAllowedTestHost(u) {
-  if (!AP_ALLOW_HOSTS.size) return false;
-  return AP_ALLOW_HOSTS.has(u.host.toLowerCase());
-}
-async function assertPublicHost(hostname) {
-  // URL.hostname geeft een IPv6-literal MET blokhaken ("[::1]"), en net.isIP
-  // herkent die vorm niet. Zonder strippen viel elk IPv6-adres door naar de
-  // DNS-tak, waar het strandde op ENOTFOUND: geweigerd, maar per ongeluk en met
-  // de verkeerde reden. isBlockedIp strippde ze al -- die verwachtte dus input
-  // die hier nooit aankwam.
-  const naakt = String(hostname || '').replace(/^\[|\]$/g, '');
-  if (net.isIP(naakt)) { if (isBlockedIp(naakt)) throw new Error('ssrf-blocked-ip'); return; }
-  const addrs = await dns.promises.lookup(naakt, { all: true });
-  if (!addrs.length || addrs.some((a) => isBlockedIp(a.address))) throw new Error('ssrf-blocked-host');
-}
-// One honest name on ALL outbound federation traffic (Robins vraag, 31-7):
-// safeFetch went out with the bare Node default before, and polite fediverse
-// citizens say who they are (some instances even refuse anonymous UAs). A
-// caller-provided User-Agent (the EmbedResolver) still wins.
-let _uaVer = '1.0';
-try { _uaVer = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url))).version || _uaVer; } catch { /* keep default */ }
-const KLONKT_UA = `Klonkt/${_uaVer} (+https://klonkt.com)`;
-
-export async function safeFetch(url, opts = {}, maxRedirects = 3) {
-  let target = url;
-  for (let hop = 0; ; hop++) {
-    const u = new URL(target); // throws on malformed → caller's catch
-    if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new Error('ssrf-bad-scheme');
-    // Alleen op de precieze host:poort uit AP_ALLOW_HOSTS, en per hop opnieuw:
-    // een omleiding naar een ANDER intern adres blijft geweigerd.
-    if (!isAllowedTestHost(u)) await assertPublicHost(u.hostname);
-    const r = await fetch(target, {
-      ...opts,
-      headers: { 'User-Agent': KLONKT_UA, ...(opts.headers || {}) },
-      redirect: 'manual',
-      signal: AbortSignal.timeout(8000),
-    });
-    const loc = (r.status >= 300 && r.status < 400) ? r.headers.get('location') : null;
-    if (loc && hop < maxRedirects) { target = new URL(loc, target).toString(); continue; }
-    return r;
-  }
-}
-
-// ── RSA keys per actor (lazy, cached in DB) ───────────────────────
-// Prepared lazily (NOT at module load) — the ap_keys table is created in
-// initializeDatabase(), which runs after this module is imported.
-let _sel, _ins;
-function keyStmts() {
-  if (!_sel) {
-    _sel = db.prepare('SELECT public_pem, private_pem FROM ap_keys WHERE slug = ?');
-    _ins = db.prepare('INSERT OR IGNORE INTO ap_keys (slug, public_pem, private_pem, created_at) VALUES (?,?,?,CURRENT_TIMESTAMP)');
-  }
-  return { sel: _sel, ins: _ins };
-}
-
-export function getOrCreateKeys(slug) {
-  const { sel, ins } = keyStmts();
-  const row = sel.get(slug);
-  if (row) return row;
-  const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
-    modulusLength: 2048,
-    publicKeyEncoding: { type: 'spki', format: 'pem' },
-    privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
-  });
-  ins.run(slug, publicKey, privateKey);
-  return sel.get(slug) || { public_pem: publicKey, private_pem: privateKey };
-}
-
-// ── HTTP Signatures + delivery ────────────────────────────────────
-// Sign + POST an activity to a remote inbox (draft-cavage HTTP Signatures, RSA-SHA256).
-export async function deliver(inboxUrl, bodyObj, keyId, privatePem) {
-  const body = JSON.stringify(bodyObj);
-  const u = new URL(inboxUrl);
-  const date = new Date().toUTCString();
-  const digest = 'SHA-256=' + crypto.createHash('sha256').update(body).digest('base64');
-  const signingString = `(request-target): post ${u.pathname}\nhost: ${u.host}\ndate: ${date}\ndigest: ${digest}`;
-  const signature = crypto.sign('sha256', Buffer.from(signingString), privatePem).toString('base64');
-  const sig = `keyId="${keyId}",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="${signature}"`;
-  const r = await safeFetch(inboxUrl, {
-    method: 'POST',
-    headers: { 'Content-Type': 'application/activity+json', Accept: 'application/activity+json', Date: date, Digest: digest, Signature: sig },
-    body,
-  });
-  return r.status;
-}
-
-export async function fetchActor(url, opts = {}) {
-  // Authorized fetch (Mastodons secure mode): zo'n instance serveert zijn
-  // actor-document -- en dus zijn publieke sleutel -- alleen aan een ONDERTEKEND
-  // verzoek en antwoordt anders met 401. Zonder sleutel kunnen we een correct
-  // ondertekende Follow van die instance niet verifiëren en wijzen we hem af,
-  // waarna Mastodon het dagenlang blijft proberen. Gemeten op boiert.eu: vier
-  // accounts eindeloos geweigerd, en precies die vier geven 401 op een
-  // onbetekende GET (shaer-afq).
-  //
-  // Geen kip-ei: om ONZE handtekening te controleren haalt de andere kant ons
-  // actor-document op, en dat serveert Klonkt publiek.
-  //
-  // ONBETEKEND EERST, en dat is een veiligheidskeuze en geen optimalisatie.
-  // verifyRequest haalt de keyId-URL op VOORDAT er iets geverifieerd is, en die
-  // URL komt uit een header die iedereen mag sturen. Tekenden we dat verzoek
-  // standaard, dan kan een volslagen onbekende ons een ONDERTEKEND verzoek laten
-  // sturen naar een adres van zijn keuze -- met onze identiteit eronder. Dat is
-  // precies hoe een instance op een blocklist belandt. Ondertekenen doen we dus
-  // pas als het onbetekend niet lukt, en dan alleen voor deze ene URL.
-  let doc = null;
-  try {
-    const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } });
-    if (r.ok) {
-      const len = Number(r.headers.get('content-length') || 0);
-      if (len > 2_000_000) return null; // refuse oversized actor docs
-      doc = await r.json();
-    }
-  } catch { /* val door naar de ondertekende poging */ }
-  // Genoeg? Dan klaar. Sommige instances serveren onbetekend wel een document
-  // maar zonder sleutel; voor een verificatie hebben we daar niets aan, dus die
-  // telt als mislukt.
-  if (doc && (!opts.asSlug || (doc.publicKey && doc.publicKey.publicKeyPem))) return doc;
-  if (!opts.asSlug) return doc;
-  const signed = await signedGetJson(opts.asSlug, url).catch(() => null);
-  return (signed && signed.id) ? signed : doc;
-}
-
-// ── Delivery queue with retries ───────────────────────────────────
-// Outbound deliveries are tried immediately; on failure (down server, timeout,
-// non-2xx) they're queued and retried with backoff so a briefly-offline follower
-// doesn't silently miss the post. The signing key is NOT stored — the worker
-// re-derives it from the actor slug at send time.
-const DELIVERY_MAX_ATTEMPTS = 6;
-const DELIVERY_BACKOFF_MIN = [1, 5, 15, 60, 180, 360];
-let _insDeliv, _dueDeliv, _delDeliv, _bumpDeliv;
-function deliveryStmts() {
-  if (!_insDeliv) {
-    _insDeliv = db.prepare('INSERT INTO ap_delivery (slug, inbox, body, attempts, next_at) VALUES (?,?,?,0,CURRENT_TIMESTAMP)');
-    _dueDeliv = db.prepare("SELECT * FROM ap_delivery WHERE datetime(next_at) <= datetime('now') ORDER BY next_at LIMIT 30");
-    _delDeliv = db.prepare('DELETE FROM ap_delivery WHERE id = ?');
-    _bumpDeliv = db.prepare('UPDATE ap_delivery SET attempts = ?, next_at = ? WHERE id = ?');
-  }
-  return { ins: _insDeliv, due: _dueDeliv, del: _delDeliv, bump: _bumpDeliv };
-}
-export function enqueueDelivery(slug, inbox, activity) {
-  if (!slug || !inbox || !activity) return;
-  try { deliveryStmts().ins.run(slug, inbox, JSON.stringify(activity)); } catch { /* ignore */ }
-}
-// Record delivery health per follower so the followers list can flag dead accounts.
-// Keyed by inbox: a shared-inbox POST reaches every follower behind it, so all of them
-// are marked. A non-follower inbox (inline @mention) simply matches 0 rows.
-let _fDelivOk, _fDelivErr;
-function markFollowerDelivery(slug, inbox, ok) {
-  if (!slug || !inbox) return;
-  try {
-    if (!_fDelivOk) {
-      _fDelivOk = db.prepare('UPDATE ap_followers SET last_delivery_at = CURRENT_TIMESTAMP WHERE slug = ? AND (inbox = ? OR shared_inbox = ?)');
-      _fDelivErr = db.prepare('UPDATE ap_followers SET last_error_at = CURRENT_TIMESTAMP WHERE slug = ? AND (inbox = ? OR shared_inbox = ?)');
-    }
-    (ok ? _fDelivOk : _fDelivErr).run(slug, inbox, inbox);
-  } catch { /* health tracking is non-fatal */ }
-}
-// Deliver now; queue for retry if it fails.
-export async function deliverWithRetry(slug, inbox, activity, keyId, privPem) {
-  if (!inbox) return;
-  try { const st = await deliver(inbox, activity, keyId, privPem); if (st >= 200 && st < 300) { markFollowerDelivery(slug, inbox, true); return; } } catch { /* queue below */ }
-  enqueueDelivery(slug, inbox, activity);
-}
-let _processingDeliv = false;
-export async function processDeliveryQueue() {
-  if (_processingDeliv) return; // re-entrancy guard: 30 rows × 8s can exceed the 60s tick → no double-delivery
-  _processingDeliv = true;
-  try {
-    let rows;
-    try { rows = deliveryStmts().due.all(); } catch { return; }
-    if (!rows || !rows.length) return;
-    const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-    for (const row of rows) {
-      let ok = false;
-      try {
-        const keys = getOrCreateKeys(row.slug);
-        const st = await deliver(row.inbox, JSON.parse(row.body), `${actorId(base, row.slug)}#main-key`, keys.private_pem);
-        ok = st >= 200 && st < 300;
-      } catch { ok = false; }
-      if (ok) { markFollowerDelivery(row.slug, row.inbox, true); deliveryStmts().del.run(row.id); continue; }
-      const attempts = row.attempts + 1;
-      if (attempts >= DELIVERY_MAX_ATTEMPTS) { markFollowerDelivery(row.slug, row.inbox, false); deliveryStmts().del.run(row.id); console.warn('[AP] delivery gave up after', attempts, 'tries →', row.inbox); continue; }
-      // Index the backoff on the CURRENT attempt count (row.attempts) so the first
-      // retry uses the 1-min tier instead of skipping it.
-      const mins = DELIVERY_BACKOFF_MIN[Math.min(row.attempts, DELIVERY_BACKOFF_MIN.length - 1)];
-      deliveryStmts().bump.run(attempts, new Date(Date.now() + mins * 60000).toISOString(), row.id);
-    }
-  } finally { _processingDeliv = false; }
-}
-let _delivTimer = null;
-export function startDeliveryWorker() {
-  if (_delivTimer) return;
-  _delivTimer = setInterval(() => { processDeliveryQueue().catch(() => {}); }, 60 * 1000);
-  if (_delivTimer.unref) _delivTimer.unref();
-}
-
-/** Een lokale site om GETs mee te ondertekenen wanneer er geen specifieke is
- *  (de gedeelde inbox). Gecached: dit draait per binnenkomend verzoek. */
-let _signSlug;
-export function anySigningSlug() {
-  if (_signSlug !== undefined) return _signSlug;
-  try { const r = db.prepare('SELECT slug FROM sites ORDER BY rowid LIMIT 1').get(); _signSlug = (r && r.slug) || null; }
-  catch { _signSlug = null; }
-  return _signSlug;
-}
-
-// Best-effort verification of an incoming signed request. Returns the sender's
-// actor doc if the signature checks out, else null. (Not gating yet — MVP.)
-// Max clock skew for the signed Date header (replay window). Generous default to tolerate
-// federating servers with drifting clocks; an operator can widen it via env.
-const SIG_MAX_SKEW_MS = (Number(process.env.AP_SIG_MAX_SKEW_MIN) || 60) * 60 * 1000;
-export async function verifyRequest(req, asSlug = null) {
-  const sigH = req.headers['signature'];
-  if (!sigH) return null;
-  const p = Object.fromEntries([...sigH.matchAll(/([a-zA-Z]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
-  if (!p.keyId || !p.signature) return null;
-  // Onderteken de sleutel-ophaal, anders faalt elke instance met authorized
-  // fetch (shaer-afq). Zonder aangewezen site -- de gedeelde inbox -- tekenen we
-  // als een willekeurige lokale actor: elke Klonkt-actor is een geldige
-  // ondertekenaar, het gaat de andere kant er alleen om DAT er ondertekend is.
-  const actor = await fetchActor(p.keyId.split('#')[0], { asSlug: asSlug || anySigningSlug() });
-  const pem = actor && actor.publicKey && actor.publicKey.publicKeyPem;
-  if (!pem) return null;
-  // Bind the key to the actor it speaks for. Without this we hand back whatever
-  // `id` the fetched document claims, so anyone could host a document carrying a
-  // VICTIM's id next to their OWN public key, sign with their own private half,
-  // and be believed: the victim's server is never contacted. The caller decides on
-  // `verified.id`, so the identity has to come from where the key was FETCHED,
-  // never from what the document says about itself.
-  // Adds conditions only, and there is no exemption list on purpose: an
-  // "unless it's a known peer" escape hatch is exactly the door this closes.
-  // Note this does not narrow what we accept in practice, since the line above
-  // already requires the embedded publicKey object (an array or a bare URI
-  // reference never worked here).
-  const key = actor.publicKey;
-  try {
-    if (new URL(p.keyId).host !== new URL(actor.id).host) return null;   // same origin as the key
-    if (key.id && key.id !== p.keyId) return null;                       // this key, not a neighbour's
-    if (key.owner && key.owner !== actor.id) return null;                // and it belongs to this actor
-  } catch { return null; }                                               // unparseable id or keyId
-  const hs = (p.headers || '(request-target) host date').split(/\s+/);
-  // Behind a reverse proxy the raw Host header is the backend bind (e.g. localhost:3000, when
-  // the proxy doesn't preserve it — Apache .htaccess [P] proxying), but the sender signed the
-  // HTTP-Signature over the PUBLIC host. Try each candidate host (the configured PUBLIC_BASE_URL
-  // host, the proxy's X-Forwarded-Host, and the raw Host) and accept if the signature verifies
-  // against any. An attacker can't forge a match (no private key), so this only rescues the
-  // legitimate proxied case. Also normalise a leading double-slash in the request-target.
-  let _pubHost = null;
-  if (process.env.PUBLIC_BASE_URL) { try { _pubHost = new URL(process.env.PUBLIC_BASE_URL).host; } catch { /* ignore */ } }
-  const _hosts = [...new Set([_pubHost, req.headers['x-forwarded-host'], req.headers['host']].filter(Boolean))];
-  const _target = `${req.method.toLowerCase()} ${String(req.originalUrl || '').replace(/^\/{2,}/, '/')}`;
-  const _sig = Buffer.from(p.signature, 'base64');
-  let ok = false;
-  for (const _h of _hosts) {
-    const line = hs.map((x) => x === '(request-target)'
-      ? `(request-target): ${_target}`
-      : x === 'host' ? `host: ${_h}`
-      : `${x}: ${req.headers[x] || ''}`).join('\n');
-    try { if (crypto.verify('sha256', Buffer.from(line), pem, _sig)) { ok = true; break; } } catch { /* try next host */ }
-  }
-  // Replay defence: the Date header must be signed and recent. A captured signed request
-  // replayed later (or with a swapped body) is rejected.
-  if (ok) {
-    if (!hs.includes('date')) ok = false;
-    else {
-      const t = Date.parse(req.headers['date'] || '');
-      if (isNaN(t) || Math.abs(Date.now() - t) > SIG_MAX_SKEW_MS) ok = false;
-    }
-  }
-  // Digest is MANDATORY when the request carries a body: without a signed digest the body
-  // isn't covered by the signature and could be swapped on a replay.
-  if (ok && req.rawBody && req.rawBody.length) {
-    if (!hs.includes('digest')) ok = false;
-    else {
-      const exp = 'SHA-256=' + crypto.createHash('sha256').update(req.rawBody).digest('base64');
-      if (req.headers['digest'] !== exp) ok = false;
-    }
-  }
-  return ok ? actor : null;
-}
-
-// A generic SSRF-safe AP GET (collections / pages).
-/**
- * A signed GET as one of our local actors (friends-history, 30-7): the remote
- * server can then recognise the caller and serve what THAT caller may see,
- * exactly like the guardian's authorized fetch. The signature covers
- * (request-target) host date, the set verifyRequest checks.
- */
-/**
- * De handtekening-headers voor een GET als `slug`. Losgetrokken uit
- * signedGetJson omdat een verhuizing ook BYTES moet kunnen ophalen (FEP-1580:
- * gehoste audio zit achter dezelfde poort als de rest, en een ongetekende fetch
- * krijgt daar terecht een 403).
- */
-export function signedGetHeaders(slug, url, accept = 'application/activity+json') {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !slug) return null;
-  const me = actorId(base, slug);
-  const keys = getOrCreateKeys(slug);
-  const u = new URL(url);
-  const date = new Date().toUTCString();
-  const target = `${u.pathname}${u.search || ''}`;
-  const signingString = `(request-target): get ${target}\nhost: ${u.host}\ndate: ${date}`;
-  const signature = crypto.sign('sha256', Buffer.from(signingString), keys.private_pem).toString('base64');
-  return {
-    Accept: accept,
-    Date: date,
-    Signature: `keyId="${me}#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="${signature}"`,
-  };
-}
-
-export async function signedGetJson(slug, url, onStatus) {
-  try {
-    const headers = signedGetHeaders(slug, url);
-    if (!headers) return apGetJson(url);
-    const r = await safeFetch(url, { headers });
-    // De status doorgeven aan wie erom vroeg: null alleen zegt "het lukte
-    // niet", en dat is te weinig om een WEIGERING van een STORING te
-    // onderscheiden. Wie geen callback meegeeft merkt hier niets van.
-    if (typeof onStatus === 'function') onStatus(r.status);
-    if (!r.ok) return null;
-    const len = Number(r.headers.get('content-length') || 0);
-    if (len > 3_000_000) return null;
-    return await r.json();
-  } catch { return null; }
-}
-
-export async function apGetJson(url) {
-  try {
-    const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } });
-    if (!r.ok) return null;
-    const len = Number(r.headers.get('content-length') || 0);
-    if (len > 3_000_000) return null;
-    return await r.json();
-  } catch { return null; }
-}
Index: src/services/ensurePrimarySite.js
===================================================================
--- src/services/ensurePrimarySite.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,52 +1,0 @@
-import { v4 as uuid } from 'uuid';
-import db from '../config/database.js';
-
-// A Klonkt instance should ALWAYS have a primary site — it carries the identity
-// (title, theme, profile) and is the anchor point for the whole instance.
-// The register flow already creates one, but an admin created via a script
-// (or an empty sites table for any reason) left the instance without a site:
-// no settings, dashboard would crash.
-//
-// This helper runs at boot (and is idempotent): as soon as there is an admin
-// but no site yet, it creates a default site owned by the first god/admin.
-
-function defaultTitle() {
-  try {
-    const base = process.env.PUBLIC_BASE_URL;
-    if (base) {
-      const host = new URL(base).hostname.replace(/^www\./, '');
-      const label = host.split('.')[0];
-      if (label) return label.charAt(0).toUpperCase() + label.slice(1);
-    }
-  } catch { /* fall back to generic */ }
-  return 'Mijn site';
-}
-
-export function ensurePrimarySite() {
-  const count = db.prepare('SELECT COUNT(*) AS c FROM sites').get().c;
-  if (count > 0) return null; // a site already exists — nothing to do
-
-  const owner = db.prepare(
-    "SELECT id FROM users WHERE role IN ('god','admin') ORDER BY created_at LIMIT 1"
-  ).get();
-  if (!owner) return null; // no admin yet -> no owner, nothing to create
-
-  const siteId = uuid();
-  const slug = 'main'; // not reserved; in solo mode the primary site is always pinned anyway
-  db.prepare(`
-    INSERT INTO sites (
-      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, is_primary
-    ) VALUES (?, ?, ?, '', '', ?, 'en', 'klonkt', '#e8b04b', NULL, 1, 1, 1, 1, 'grid', 1)
-  `).run(siteId, slug, defaultTitle(), owner.id);
-
-  // site_members-entry zodat de owner door canAdminSite-checks komt.
-  db.prepare(
-    "INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')"
-  ).run(siteId, owner.id);
-
-  console.log(`[ensurePrimarySite] standaard-site '${slug}' aangemaakt (owner ${owner.id})`);
-  return { siteId, slug };
-}
Index: src/services/guardianship/availability.js
===================================================================
--- src/services/guardianship/availability.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,294 +1,0 @@
-/**
- * Guardian availability (FEP-633c §3.6): away, dormant, and the lapse.
- *
- * The port of the Shaer test daemon's availability.rs, validated there first
- * (shaer-8z7): same states, same rules, same refusals. Guardianship demands
- * attention; `shaer:guardians` is a public claim about safety, and a guardian
- * who no longer answers makes it untrue. It also quietly breaks the §3.5
- * arithmetic: a majority of a set with absent members can be unreachable.
- *
- * Three states per (ward, guardian), and one rule above everything else:
- * ONE ANSWER RESTORES EVERYTHING, at any moment up to and including a
- * running lapse. Neither away nor dormant is misconduct; neither leaves a
- * mark.
- *
- * Time is always a parameter here, never read from a clock inside the rules,
- * so a fourteen-day window is a number in a test and not a wait.
- */
-import db from '../../config/database.js';
-import { listGuardians, removeRelation } from './relations.js';
-
-/** Deployment numbers (§3.6.2 keeps them out of the spec on purpose: any
- *  number written there would punish exactly the long-term ill). Matched to
- *  the daemon's defaults so the two backends behave the same under test. */
-export const POLICY = {
-  requestTtlMs: 7 * 24 * 3600 * 1000,   // how long a request may sit unanswered
-  missesForDormant: 3,                   // how many missed requests make dormant
-};
-
-/** The lapse window. Irreversible per §3.5, so it always runs in full. */
-export const LAPSE_WINDOW_MS = 14 * 24 * 3600 * 1000;
-
-/** Marker detection: the away declaration rides a direct note (§2.4). */
-export function isAway(object) {
-  return !!object && (object['shaer:away'] === true || object.away === true);
-}
-
-/** AS2 endTime → epoch ms. A number passes through; a string goes through
- *  Date.parse (which reads ISO 8601, offsets included). null when absent or
- *  unreadable: an absence without an end is refused, never guessed. */
-export function parseEndTime(v) {
-  if (typeof v === 'number' && Number.isFinite(v)) return v;
-  if (typeof v !== 'string' || !v.trim()) return null;
-  const t = Date.parse(v);
-  return Number.isNaN(t) ? null : t;
-}
-
-// ── Attention (the per-guardian state) ─────────────────────────────────────
-
-function attentionRow(wardSlug, guardianUri) {
-  return db.prepare('SELECT * FROM ap_guardian_attention WHERE ward_slug = ? AND guardian_uri = ?')
-    .get(wardSlug, guardianUri) || { ward_slug: wardSlug, guardian_uri: guardianUri, state: 'active', away_until: null };
-}
-
-/** What the stored state means at `now`: an away past its end is simply
- *  active again, silently (§3.6.1). */
-export function effective(wardSlug, guardianUri, now) {
-  const row = attentionRow(wardSlug, guardianUri);
-  if (row.state === 'away') return (row.away_until && now < row.away_until) ? 'away' : 'active';
-  return row.state;
-}
-
-/** The away end, when there is a running one (for display). */
-export function awayUntil(wardSlug, guardianUri, now) {
-  const row = attentionRow(wardSlug, guardianUri);
-  return (row.state === 'away' && row.away_until && now < row.away_until) ? row.away_until : null;
-}
-
-/** Declare absence with an end (§3.6.1). The declaration is itself an
- *  answer, so it first restores: declaring away while dormant clears the
- *  dormancy, without a mark. Declaring away is the responsible act. */
-export function declareAway(wardSlug, guardianUri, untilMs) {
-  db.prepare('DELETE FROM ap_attention_requests WHERE ward_slug = ? AND guardian_uri = ?').run(wardSlug, guardianUri);
-  db.prepare(`INSERT INTO ap_guardian_attention (ward_slug, guardian_uri, state, away_until)
-              VALUES (?,?, 'away', ?)
-              ON CONFLICT(ward_slug, guardian_uri) DO UPDATE SET state = 'away', away_until = excluded.away_until`)
-    .run(wardSlug, guardianUri, untilMs);
-}
-
-/** A directly addressed request went out to this guardian (a §3.5 decision
- *  naming them, or an explicit check-in). Requests during a declared absence
- *  are not recorded: away MUST NOT count as evidence (§3.6.1). */
-export function recordRequest(wardSlug, guardianUri, requestId, now) {
-  if (effective(wardSlug, guardianUri, now) === 'away') return;
-  db.prepare(`INSERT OR IGNORE INTO ap_attention_requests (ward_slug, guardian_uri, request_id, asked_at)
-              VALUES (?,?,?,?)`).run(wardSlug, guardianUri, requestId, now);
-}
-
-/** Missed requests: unanswered ones older than the policy TTL. */
-export function misses(wardSlug, guardianUri, now) {
-  const r = db.prepare(`SELECT COUNT(*) AS n FROM ap_attention_requests
-                        WHERE ward_slug = ? AND guardian_uri = ? AND asked_at <= ?`)
-    .get(wardSlug, guardianUri, now - POLICY.requestTtlMs);
-  return r ? r.n : 0;
-}
-
-/** Promote to dormant when the evidence says so. Returns true only on the
- *  transition itself: THAT is the moment the notification duty of §3.6.2
- *  fires (protocol AND the §6 handle), and it is the caller's job — wired
- *  through onDormant below so every call site notifies the same way. */
-export function observe(wardSlug, guardianUri, now) {
-  if (effective(wardSlug, guardianUri, now) !== 'active') return false;
-  if (misses(wardSlug, guardianUri, now) < POLICY.missesForDormant) return false;
-  db.prepare(`INSERT INTO ap_guardian_attention (ward_slug, guardian_uri, state, away_until)
-              VALUES (?,?, 'dormant', NULL)
-              ON CONFLICT(ward_slug, guardian_uri) DO UPDATE SET state = 'dormant', away_until = NULL`)
-    .run(wardSlug, guardianUri);
-  notifyDormant(wardSlug, guardianUri);
-  return true;
-}
-
-/** The notification duty of §3.6.2, wired once (ActivityPubService). The
- *  one-answer rule is worthless to someone who does not know an answer is
- *  wanted; the §6 handle exists for precisely this moment. */
-let _onDormant = null;
-export function wireAvailability({ onDormant } = {}) { _onDormant = onDormant || null; }
-function notifyDormant(wardSlug, guardianUri) {
-  try { if (_onDormant) _onDormant(wardSlug, guardianUri); } catch { /* best-effort */ }
-}
-
-/**
- * One answer restores everything (§3.6). Any activity from an actor that
- * guards someone on this server restores it to active for those wards and
- * cancels any lapse running against it, up to the last moment of the window.
- * Returns what changed, so a caller can log or announce it.
- */
-export function oneAnswer(guardianUri, now) {
-  if (!guardianUri) return { restored: [], cancelledLapses: [] };
-  const restored = [];
-  for (const row of db.prepare(`SELECT ward_slug, state FROM ap_guardian_attention WHERE guardian_uri = ?`).all(guardianUri)) {
-    if (row.state !== 'active') restored.push(row.ward_slug);
-  }
-  const hadRequests = db.prepare('SELECT DISTINCT ward_slug FROM ap_attention_requests WHERE guardian_uri = ?').all(guardianUri);
-  for (const r of hadRequests) if (!restored.includes(r.ward_slug)) restored.push(r.ward_slug);
-  db.prepare("UPDATE ap_guardian_attention SET state = 'active', away_until = NULL WHERE guardian_uri = ?").run(guardianUri);
-  db.prepare('DELETE FROM ap_attention_requests WHERE guardian_uri = ?').run(guardianUri);
-
-  const cancelledLapses = [];
-  for (const l of db.prepare('SELECT * FROM ap_lapses WHERE target_uri = ? AND cancelled = 0 AND applied = 0').all(guardianUri)) {
-    if (lapseOutcome(l, now) === 'open') {
-      db.prepare('UPDATE ap_lapses SET cancelled = 1 WHERE id = ?').run(l.id);
-      cancelledLapses.push({ id: l.id, wardSlug: l.ward_slug, wardUri: l.ward_uri, set: JSON.parse(l.set_json) });
-    }
-  }
-  return { restored, cancelledLapses };
-}
-
-/** The available set of §3.5: the guardians minus away and dormant members.
- *  Observation (and thus the dormancy promotion) happens here, so reading the
- *  set is what moves the clock's consequences. */
-export function availableSet(wardSlug, guardianUris, now) {
-  return guardianUris.filter((g) => {
-    observe(wardSlug, g, now);
-    return effective(wardSlug, g, now) === 'active';
-  });
-}
-
-/** The guardians queue items (§3.6.1: never public, owner-only): the real
- *  size of the ward's safety net. Same shape the daemon serves. */
-export function statusesFor(wardSlug, guardianUris, now) {
-  return guardianUris.map((g) => {
-    observe(wardSlug, g, now);
-    const running = db.prepare(`SELECT id FROM ap_lapses WHERE ward_slug = ? AND target_uri = ? AND cancelled = 0 AND applied = 0`)
-      .get(wardSlug, g);
-    return {
-      id: g,
-      'shaer:availability': effective(wardSlug, g, now),
-      'shaer:awayUntil': awayUntil(wardSlug, g, now),
-      'shaer:lapse': running && lapseOutcome(db.prepare('SELECT * FROM ap_lapses WHERE id = ?').get(running.id), now) === 'open' ? running.id : null,
-    };
-  });
-}
-
-// ── The lapse (§3.6.3): release in absentia ────────────────────────────────
-
-/** Read a shaer:Lapse object, or null when this is a different Offer. */
-export function parseLapse(object) {
-  if (!object || typeof object !== 'object') return null;
-  const type = Array.isArray(object.type) ? object.type[0] : object.type;
-  if (type !== 'shaer:Lapse' && type !== 'Lapse') return null;
-  const ward = object['shaer:ward'] || object.ward;
-  const target = typeof object.object === 'string' ? object.object : (object.object && object.object.id);
-  return (typeof ward === 'string' && typeof target === 'string') ? { ward, target } : null;
-}
-
-/** Strict majority of the set (§3.5 default). */
-export function lapseThreshold(setSize) { return Math.floor(setSize / 2) + 1; }
-
-/** Pure outcome: cancelled beats everything; the window always runs in full
- *  (§3.5, irreversible), then a strict majority completes, else it fails
- *  closed. */
-export function lapseOutcome(row, now) {
-  if (!row) return null;
-  if (row.cancelled) return 'cancelled';
-  if (now - row.opened_at < row.window_ms) return 'open';
-  const accepts = JSON.parse(row.accepts_json).length;
-  return accepts >= lapseThreshold(JSON.parse(row.set_json).length) ? 'completed' : 'failed';
-}
-
-/**
- * Open a lapse on this server (we host the ward). Refusals mirror the
- * daemon's, status for status:
- *  - not_a_guardian: the target does not guard this ward
- *  - would_emancipate: removing the last guardian is §3.4, never a lapse
- *  - not_dormant: a lapse opens only against a guardian already dormant
- *  - not_in_available_set: only an available co-guardian proposes
- */
-export function openLapse({ id, wardSlug, wardUri, target, openedBy, now, windowMs = LAPSE_WINDOW_MS }) {
-  const guardians = listGuardians(wardSlug).map((g) => g.other_uri);
-  if (!guardians.includes(target)) return { error: 'not_a_guardian' };
-  if (guardians.length <= 1) return { error: 'would_emancipate' };
-  observe(wardSlug, target, now);
-  if (effective(wardSlug, target, now) !== 'dormant') return { error: 'not_dormant' };
-  const set = availableSet(wardSlug, guardians, now).filter((g) => g !== target);
-  if (!set.includes(openedBy)) return { error: 'not_in_available_set' };
-  // The proposal carries the proposer's own accept (§3.1's one-step clause,
-  // exactly as §5.6 applies it).
-  db.prepare(`INSERT INTO ap_lapses (id, ward_slug, ward_uri, target_uri, opened_by, set_json, accepts_json, opened_at, window_ms)
-              VALUES (?,?,?,?,?,?,?,?,?)`)
-    .run(id, wardSlug, wardUri, target, openedBy, JSON.stringify(set), JSON.stringify([openedBy]), now, windowMs);
-  return { lapse: db.prepare('SELECT * FROM ap_lapses WHERE id = ?').get(id), set, threshold: lapseThreshold(set.length) };
-}
-
-/** Record a vote from a set member. Answers from outside the snapshot are
- *  refused, not counted: a stranger cannot make up the majority. */
-export function lapseVote(id, actor, accept, now) {
-  const row = db.prepare('SELECT * FROM ap_lapses WHERE id = ?').get(id);
-  if (!row) return null;
-  const outcome = lapseOutcome(row, now);
-  if (outcome !== 'open') return { error: outcome === 'cancelled' ? 'cancelled' : 'closed' };
-  const set = JSON.parse(row.set_json);
-  if (!set.includes(actor)) return { error: 'not_in_set' };
-  const accepts = new Set(JSON.parse(row.accepts_json));
-  const rejects = new Set(JSON.parse(row.rejects_json));
-  if (accept) { rejects.delete(actor); accepts.add(actor); }
-  else { accepts.delete(actor); rejects.add(actor); }
-  db.prepare('UPDATE ap_lapses SET accepts_json = ?, rejects_json = ? WHERE id = ?')
-    .run(JSON.stringify([...accepts]), JSON.stringify([...rejects]), id);
-  return { outcome: 'open', accepts: accepts.size, threshold: lapseThreshold(set.length) };
-}
-
-/**
- * Evaluate a lapse at `now`, executing the removal exactly once when the
- * window has closed with a majority. The refusal to empty shaer:guardians
- * stands as a second lock under this one: even a completed lapse must not
- * take the last guardian (that is emancipation, §3.4).
- */
-export function settleLapse(id, now) {
-  const row = db.prepare('SELECT * FROM ap_lapses WHERE id = ?').get(id);
-  if (!row) return null;
-  const outcome = lapseOutcome(row, now);
-  if (outcome !== 'completed' || row.applied) return { outcome, applied: !!row.applied, row };
-  if (listGuardians(row.ward_slug).length <= 1) {
-    return { outcome, applied: false, refused: 'would_emancipate', row };
-  }
-  removeRelation(row.ward_slug, 'ward', row.target_uri);
-  db.prepare('UPDATE ap_lapses SET applied = 1 WHERE id = ?').run(id);
-  return { outcome, applied: true, row };
-}
-
-/** The offers-queue items for running lapses this account is a party to:
- *  the ward itself, or a co-located guardian in the set. Same shape as the
- *  daemon's, so the Shaer clients render them as-is. */
-export function lapseQueueItems(slug, me, now) {
-  const items = [];
-  for (const row of db.prepare('SELECT * FROM ap_lapses WHERE applied = 0 AND cancelled = 0').all()) {
-    settleLapse(row.id, now);   // reads are where lazy completion happens
-    if (lapseOutcome(row, now) !== 'open') continue;
-    const set = JSON.parse(row.set_json);
-    if (row.ward_slug !== slug && !set.includes(me)) continue;
-    const accepts = JSON.parse(row.accepts_json);
-    const rejects = JSON.parse(row.rejects_json);
-    items.push({
-      id: row.id,
-      type: 'Offer',
-      actor: row.opened_by,
-      object: { type: 'shaer:Lapse', 'shaer:ward': row.ward_uri, object: row.target_uri },
-      'shaer:set': set,
-      'shaer:accepts': accepts.length,
-      'shaer:threshold': lapseThreshold(set.length),
-      'shaer:myVote': accepts.includes(me) || rejects.includes(me),
-      'shaer:outcome': 'open',
-      'shaer:closesAt': row.opened_at + row.window_ms,
-    });
-  }
-  return items;
-}
-
-export function getLapse(id) { return db.prepare('SELECT * FROM ap_lapses WHERE id = ?').get(id); }
-
-export default {
-  POLICY, LAPSE_WINDOW_MS, isAway, parseEndTime, effective, awayUntil, declareAway,
-  recordRequest, misses, observe, oneAnswer, availableSet, statusesFor, wireAvailability,
-  parseLapse, lapseThreshold, lapseOutcome, openLapse, lapseVote, settleLapse, lapseQueueItems, getLapse,
-};
Index: src/services/guardianship/context.js
===================================================================
--- src/services/guardianship/context.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,43 +1,0 @@
-/**
- * Guardianship (FEP-633c "Guardians") — JSON-LD vocabulary.
- *
- * One source of truth for the shaer namespace and the terms Klonkt emits.
- * ActivityPubService spreads SHAER_CONTEXT into its AP_CONTEXT term block, so
- * every outgoing document declares the namespace and strict JSON-LD
- * processors resolve the terms instead of dropping them.
- */
-
-/** The term block merged into AP_CONTEXT. */
-export const SHAER_CONTEXT = {
-  // FEP-633c (Guardians): the shaer namespace. helpRequest marks a direct
-  // note as a ward's call for help (spec 5.2.1); ignorable by everyone else.
-  shaer: 'https://ns.klonkt.com/shaer#',
-};
-
-/** The Relationship value in the adoption Offer (FEP-633c §3), both forms. */
-export const GUARDIAN_RELATIONSHIP = 'https://ns.klonkt.com/shaer#Guardian';
-export const GUARDIAN_RELATIONSHIP_COMPACT = 'shaer:Guardian';
-
-/** True when an Offer's relationship names the guardian relation. */
-export function isGuardianRelationship(value) {
-  return value === GUARDIAN_RELATIONSHIP || value === GUARDIAN_RELATIONSHIP_COMPACT;
-}
-
-/**
- * True when an actor document carries `shaer:guardians` — i.e. it is a ward,
- * and therefore not a valid guardian (§1). The one question §4 asks, in both
- * places it asks it: before committing a guardianship (§4.2) and before
- * delivering an escalation to one (§4.1).
- *
- * §2.1 allows the list as an array of URIs, a single URI, or a Collection, so
- * all three are read here rather than in each caller.
- */
-export function carriesGuardians(doc) {
-  const g = doc && doc['shaer:guardians'];
-  if (Array.isArray(g)) return g.length > 0;
-  if (typeof g === 'string') return g.length > 0;
-  if (g && typeof g === 'object') return Array.isArray(g.items) ? g.items.length > 0 : true;
-  return false;
-}
-
-export default { SHAER_CONTEXT, GUARDIAN_RELATIONSHIP, GUARDIAN_RELATIONSHIP_COMPACT, isGuardianRelationship, carriesGuardians };
Index: src/services/guardianship/delivery.js
===================================================================
--- src/services/guardianship/delivery.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,168 +1,0 @@
-/**
- * Guardianship (FEP-633c) — the direct-note delivery leg.
- *
- * A direct note (private mention, shaer-tqc) is the ward's call-for-help
- * carrier: addressed to specific actors only, no Public, no followers
- * fan-out. Moved here from ActivityPubService (guardianship refactor);
- * behavior is unchanged.
- *
- * This module has NO import back into ActivityPubService: the AP helpers it
- * needs (actor fetch, key material, delivery, note building) are provided
- * once via wireDelivery(deps) at ActivityPubService load time.
- */
-import crypto from 'crypto';
-import db, { NU_ISO } from '../../config/database.js';
-import { carriesGuardians } from './context.js';
-
-const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
-
-let deps = null;
-/** Called once by ActivityPubService with the shared AP helpers. */
-export function wireDelivery(d) { deps = d; }
-
-// Addressing → visibility. Arrays or bare strings; unknown shapes read as the
-// safest bucket they match.
-export function c2sVisibility(object) {
-  const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string');
-  const to = arr(object.to), cc = arr(object.cc);
-  const isPublic = (x) => x === PUBLIC || x === 'as:Public' || x === 'Public';
-  const isFollowers = (x) => /\/followers\/?$/.test(x);
-  if (to.some(isPublic)) return 'public';
-  if (cc.some(isPublic)) return 'quiet';
-  if (to.some(isFollowers) || cc.some(isFollowers)) return 'friends';
-  if (!to.length && !cc.length) return 'public';   // no addressing at all: legacy client, keep old behavior
-  return 'direct';
-}
-
-// A direct note: a NEW conversation (or a direct reply) addressed to specific
-// actors only. Stored in ap_outbox with visibility 'direct' + the recipient
-// list, delivered to exactly those inboxes: no followers fan-out, no Public,
-// so no boosts and no timelines. The same S2S leg a Mastodon DM takes, so a
-// guardian on any instance receives it as a private mention (the ward
-// call-for-help path).
-export async function deliverDirectNote(site, { recipients, text, html, language, inReplyTo, attachments, helpRequest, wave, awayUntil, helpMark, gateRequest }) {
-  // EEN MARKERING IS EEN ANTWOORD op de hulpvraag waar hij over gaat (Barts
-  // vraag, 26-8: "welke context heeft 'Ik kijk hiernaar' als ik erop klik?" --
-  // geen). De verwijzing reisde al mee als shaer:-veld, maar dat veld haalt de
-  // berichtenlezing niet, en de tik-route van de clients volgt inReplyTo.
-  // Dus zeggen we het ook in gewoon AS2: dan opent een tik de draad met de
-  // schermafdruk erbij, en threaden andere fediverse-servers hem net zo goed.
-  if (!inReplyTo && helpMark && helpMark.noteUri) inReplyTo = helpMark.noteUri;
-  const { actorId, fetchActor, localActor, deliverTo, deriveHandle, escHtml, linkUrls, linkHashtags,
-          getOutboxRow, buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery } = deps;
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const list = [...new Set((recipients || []).filter((u) => /^https?:\/\//i.test(String(u || ''))))].slice(0, 8);
-  if (!base || !site || !site.slug || !list.length || !String(text || '').trim()) return null;
-  const me = actorId(base, site.slug);
-  // Resolve every recipient for a mention anchor + a delivery inbox.
-  const resolved = [];
-  const teapots = [];
-  for (const uri of list) {
-    // An actor we host is read from our own database, not fetched from our own
-    // hostname: that request has to leave the machine and come back, and when
-    // it does not, the recipient is silently dropped from the note. Everything
-    // that decides anything still runs below, for local and remote alike.
-    // ONDERTEKEND ophalen als het onbetekend niet lukt (asSlug). Een instance
-    // met Mastodons secure mode -- infosec.exchange bijvoorbeeld -- antwoordt
-    // 401 op een anonieme GET van het actor-document. Zonder document geen
-    // inbox, dus viel de ontvanger hier stil weg, en met de laatste ontvanger
-    // gaf deliverDirectNote null terug: "502 direct_failed", zonder te zeggen
-    // wie er niet bereikbaar was.
-    //
-    // Dezelfde les als bij het volgen vanaf een boost (Robins melding, 31-7):
-    // die weg kreeg toen signedGetJson, deze niet. fetchActor probeert nog
-    // steeds ONBETEKEND eerst -- dat blijft de veiligheidskeuze -- en tekent
-    // alleen deze ene URL als dat mislukt.
-    const a = (localActor && localActor(uri))
-      || await fetchActor(uri, { asSlug: site.slug }).catch(() => null);
-    if (!a || !(a.inbox || (a.endpoints && a.endpoints.sharedInbox))) continue;
-    // FEP-633c §4.1: an escalation addressed to a "guardian" that carries
-    // guardians of its own goes nowhere. There is no grand-guardian, so we
-    // MUST NOT recurse to that actor's guardians — and we fail SOFTLY: drop
-    // this one target and keep delivering to the rest, because a malformed
-    // guardian must never cost a child the guardians who are fine.
-    //
-    // Only for a call for help. An ordinary direct note is not an escalation,
-    // and a ward is perfectly entitled to message another ward.
-    if (helpRequest && carriesGuardians(a)) { teapots.push(uri); continue; }
-    resolved.push({ uri, inbox: (a.endpoints && a.endpoints.sharedInbox) || a.inbox, local: !!a.local, handle: deriveHandle(uri), url: a.url || uri });
-  }
-  if (teapots.length) console.warn('[AP] not a teapot: escalation dropped for malformed guardian(s)', teapots.join(', '));
-  if (!resolved.length) {
-    // Every guardian was malformed. §4 does not say what to do here because
-    // §4.1 assumes there are others to continue to — but a ward whose whole
-    // safety net is broken has just called for help into nothing, which is the
-    // one outcome this FEP exists to prevent. Say so loudly; the caller can
-    // tell "nobody was reachable" from "nobody was valid".
-    if (teapots.length) console.error('[AP] EVERY guardian of', site.slug, 'is malformed: the call for help reached no one');
-    return null;
-  }
-  const mention = resolved.map((r) => {
-    const disp = r.handle && r.handle[0] === '@' ? r.handle : '@' + (r.handle || '');
-    return `<a href="${escHtml(r.url)}" class="u-url mention" data-actor="${escHtml(r.uri)}">${escHtml(disp)}</a> `;
-  }).join('');
-  // Rijk antwoord: `html` is de HTML uit de reply-editor, hier gesaneerd; `text`
-  // blijft de platte versie (het `source`-veld en de no-JS-fallback). Levert de
-  // sanitizer niets bruikbaars op, dan valt hij terug op de escaped tekst --
-  // een leeggepoetste editor mag geen leeg bericht versturen.
-  const richClean = html ? deps.sanitizeHtml(String(html)) : '';
-  const rich = richClean && deps.htmlToPlainText(richClean).trim() ? richClean : '';
-  const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
-  // De mention-anker blijft een eigen alinea vooraan: de ontvanger moet in het
-  // bericht genoemd staan, ook als de rijke inhoud met een kop of lijst begint.
-  const content = rich
-    ? `<p>${mention}</p>${linkUrls(linkHashtags(base, rich))}`
-    : `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`;
-  const lang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null;
-  // Attachments: same rules as deliverReply (own /media/ uploads only,
-  // image/audio/video, max 4) — the help-buoy capture rides this.
-  const media = (Array.isArray(attachments) ? attachments : [])
-    .filter((a) => a && typeof a.url === 'string' && /^\/media\/[\w./-]+$/.test(a.url)
-      && /^(image|audio|video)\//.test(String(a.mediaType || '')))
-    .slice(0, 4)
-    .map((a) => ({ url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) }));
-  const id = crypto.randomUUID();
-  db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, language, attachments, visibility, to_actors, help_request, wave, away_until, created_at)
-              VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,${NU_ISO})`)
-    .run(id, site.slug, '', null, inReplyTo || null, resolved[0].uri, resolved[0].handle, content, lang, media.length ? JSON.stringify(media) : null, 'direct', JSON.stringify(resolved.map((r) => r.uri)), helpRequest ? 1 : 0, wave ? 1 : 0, awayUntil || null);
-  const row = getOutboxRow(id);
-  const note = buildReplyNote(base, site, row);
-  // Markering op een hulpvraag (shaer-lgo): een gewone directe note die er een
-  // shaer:-eigenschap bij draagt, net als de zwaai. Zo reist het over dezelfde
-  // bezorging, ziet de ward het als bericht ("er komt iemand"), en houden de
-  // mede-guardians er staat aan over.
-  if (helpMark && helpMark.noteUri) {
-    note[helpMark.kind === 'handled' ? 'shaer:helpHandled' : 'shaer:helpPickup'] = helpMark.noteUri;
-  }
-  // Een kind dat zelf om een poort vraagt (shaer-8ru). Alleen de naam van de
-  // feature reist mee -- geen vrije tekst, zie gatereq.js.
-  if (gateRequest) note['shaer:gateRequest'] = String(gateRequest);
-  const create = {
-    '@context': AP_CONTEXT,
-    id: note.id + '#create', type: 'Create', actor: me,
-    published: note.published, to: note.to, cc: note.cc, object: note,
-  };
-  const keys = getOrCreateKeys(site.slug);
-  const keyId = `${me}#main-key`;
-  let delivered = 0;
-  // A recipient on this machine takes the loopback (deliverToActor), which
-  // hands the Create to the same inbox handler an HTTP POST would reach: the
-  // note is stored, the mention is stored, and a shaer:away on it is applied,
-  // all by the code that does it for everyone else. A hairpin POST to our own
-  // hostname is not that code path, it is a second one that only appears to be.
-  for (const r of resolved.filter((x) => x.local)) {
-    const res = await deliverTo(site, r.uri, create).catch(() => null);
-    if (res && res.delivered) delivered++;
-  }
-  // Remote: one POST per inbox, so two guardians on the same server share it.
-  for (const inbox of [...new Set(resolved.filter((x) => !x.local).map((r) => r.inbox))]) {
-    let ok = false;
-    try { const st = await deliver(inbox, create, keyId, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; }
-    if (ok) delivered++;
-    else enqueueDelivery(site.slug, inbox, create);
-  }
-  console.log('[AP] direct note', site.slug, '→', resolved.length, 'recipient(s), delivered', delivered);
-  return { id, content, delivered, teapots };
-}
-
-export default { wireDelivery, c2sVisibility, deliverDirectNote };
Index: src/services/guardianship/follows.js
===================================================================
--- src/services/guardianship/follows.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,181 +1,0 @@
-/**
- * Guardianship (FEP-633c §5.3) — follow-gating for wards.
- *
- * A `Follow` targeting a ward is NOT auto-accepted. It is held pending and
- * routed to the ward's guardians, who approve or deny. A committed guardian's
- * own Follow is auto-accepted (it needs no gate). Quorum policy per ward:
- * 'any' (one guardian suffices, default), 'all', or 'none' (open).
- *
- * This module is the store + the decision; the AP plumbing (sending the
- * Accept, inserting the follower) stays in ActivityPubService.
- */
-import db from '../../config/database.js';
-
-let _s = null;
-function stmts() {
-  if (!_s) {
-    _s = {
-      ins: db.prepare(`INSERT OR IGNORE INTO ap_pending_follows
-        (id, ward_slug, follower_uri, follower_inbox, follower_shared_inbox, follower_name, follower_handle, follower_icon, activity_json, quorum, created_at)
-        VALUES (?,?,?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`),
-      get: db.prepare('SELECT * FROM ap_pending_follows WHERE id = ?'),
-      byWard: db.prepare("SELECT * FROM ap_pending_follows WHERE ward_slug = ? AND status = 'pending' ORDER BY created_at DESC"),
-      approvers: db.prepare('SELECT guardian_uri FROM ap_pending_follow_approvals WHERE follow_id = ?'),
-      approve: db.prepare('INSERT OR IGNORE INTO ap_pending_follow_approvals (follow_id, guardian_uri, decision, created_at) VALUES (?,?,?,CURRENT_TIMESTAMP)'),
-      setStatus: db.prepare('UPDATE ap_pending_follows SET status = ? WHERE id = ?'),
-      del: db.prepare('DELETE FROM ap_pending_follows WHERE id = ?'),
-    };
-  }
-  return _s;
-}
-
-/** Record a gated follow awaiting guardian approval. */
-export function recordPending(wardSlug, f) {
-  stmts().ins.run(
-    f.id, wardSlug, f.follower, f.inbox, f.sharedInbox || null,
-    f.name || null, f.handle || null, f.icon || null,
-    JSON.stringify(f.activity || null), f.quorum || 'any',
-  );
-  return stmts().get.get(f.id);
-}
-
-export function getPending(id) { return stmts().get.get(id); }
-
-/** Pending follows for a local ward (its guardians decide). */
-export function listForWard(wardSlug) { return stmts().byWard.all(wardSlug); }
-
-/**
- * Record a guardian's decision on a pending follow. Returns
- * { outcome: 'approved'|'rejected'|'waiting', follow } so the caller can
- * send the Accept/Reject. A single reject denies; approvals meet the quorum.
- */
-/**
- * Hoeveel guardians moeten ja zeggen voor een volgverzoek (Barts besluit, 8-8).
- *
- * EENVOUDIGE MEERDERHEID: 1 van 1, 1 van 2, 2 van 3, 2 van 4. Bart: "1/2 is
- * voldoende."
- *
- * BEWUST SOEPELER DAN DE POORTDREMPEL, en dat verschil hoort uitgelegd. Een gate
- * opent een deur voor alles wat daarna komt; die vraagt om een STRIKTE
- * meerderheid (thresholdFor in gated.js: 2 van 2, 3 van 4). Een volgverzoek gaat
- * over een persoon, is met ontvolgen terug te draaien, en stond hier tot vandaag
- * op 'any' -- een enkele ja, hoeveel guardians er ook waren. Dit is dus geen
- * versoepeling maar een AANSCHERPING voor iedereen met drie of meer guardians.
- *
- * De 'all'-stand die hier stond is weg. Hij werd nergens gezet -- elke schrijver
- * gaf 'any' mee -- dus het was een keuze die niemand kon maken en die alleen in
- * de weg stond bij het lezen van deze regel.
- */
-export function followThreshold(setSize) {
-  return Math.max(1, Math.ceil(setSize / 2));
-}
-
-/**
- * Een race naar de drempel, net als de poorttelling: zodra het aantal gehaald
- * is, is het besluit gevallen.
- *
- * TODO (shaer-8vt): wie antwoordt weet niet dat hij de doorslag geeft. Bij 1 van
- * 2 is de eerste ja meteen de beslissing, en het scherm zegt dat nergens. Dat is
- * hetzelfde gat als bij de gate-voorstellen en het hoort daar samen opgelost.
- */
-export function decide(id, guardianUri, decision, guardiansOfWard) {
-  const follow = stmts().get.get(id);
-  if (!follow || follow.status !== 'pending') return { outcome: 'gone', follow };
-  stmts().approve.run(id, guardianUri, decision === 'reject' ? 'reject' : 'approve');
-  const rows = db.prepare('SELECT guardian_uri, decision FROM ap_pending_follow_approvals WHERE follow_id = ?').all(id);
-  if (rows.some((r) => r.decision === 'reject')) {
-    stmts().setStatus.run('denied', id);
-    return { outcome: 'rejected', follow };
-  }
-  const approvers = new Set(rows.filter((r) => r.decision === 'approve').map((r) => r.guardian_uri));
-  const guardians = (guardiansOfWard || []).filter(Boolean);
-  const enough = approvers.size >= followThreshold(guardians.length);
-  if (enough) {
-    stmts().setStatus.run('accepted', id);
-    return { outcome: 'approved', follow };
-  }
-  return { outcome: 'waiting', follow };
-}
-
-export function remove(id) { stmts().del.run(id); }
-
-// ── Guardian-side copy (cross-instance, modelled on the guardian offer): a
-//    gated follow on a REMOTE ward this account guards, forwarded here as an
-//    Offer(Follow). The decision is Accept/Reject sent back to ward_inbox. ──
-let _r = null;
-function rstmts() {
-  if (!_r) {
-    _r = {
-      ins: db.prepare(`INSERT OR IGNORE INTO ap_follow_reviews
-        (id, guardian_slug, ward_uri, ward_inbox, follower_uri, follower_handle, follower_icon, follow_json,
-         direction, target_uri, target_handle, created_at)
-        VALUES (?,?,?,?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`),
-      get: db.prepare('SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND id = ?'),
-      bySlug: db.prepare("SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND status = 'pending' ORDER BY created_at DESC"),
-      del: db.prepare('DELETE FROM ap_follow_reviews WHERE guardian_slug = ? AND id = ?'),
-    };
-  }
-  return _r;
-}
-
-/**
- * De guardian-zijdige kopie van een gate-verzoek op een REMOTE ward.
- *
- * `direction` is niet cosmetisch (shaer-jdb). Bij een INKOMENDE is de follower
- * iemand anders en de ward het doel. Bij een UITGAANDE is de ward zelf de
- * follower en staat het doel in het Follow-object -- die werd hiervoor
- * opgeslagen als "deze ward wil deze ward volgen", met het doel weggegooid.
- */
-export function recordReview(guardianSlug, r) {
-  const richting = r.direction === 'outgoing' ? 'outgoing' : 'incoming';
-  rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.follower, r.followerHandle || null,
-    r.followerIcon || null, r.followJson || null, richting, r.target || null, r.targetHandle || null);
-  return rstmts().get.get(guardianSlug, r.id);
-}
-
-/**
- * Een openstaande review als wachtrij-item, in dezelfde vorm die de clients al
- * lezen (offers en outgoing-follows doen het net zo).
- */
-export function reviewQueueItem(r, me, guardianCount) {
-  // guardianCount blijft WEG als we hem niet kennen. Bij een remote ward wordt
-  // de guardian-set op diens eigen server bijgehouden, en 0 sturen zou lezen als
-  // "dit kind heeft geen guardians" -- het tegenovergestelde van onbekend.
-  const stemmen = (() => {
-    try { return db.prepare('SELECT guardian_uri, decision FROM ap_pending_follow_approvals WHERE follow_id = ?').all(r.id); }
-    catch { return []; }
-  })();
-  const uitgaand = r.direction === 'outgoing';
-  return {
-    id: r.id,
-    type: 'Follow',
-    // Bij een uitgaande is de WARD de volger; bij een inkomende is dat de vreemde.
-    actor: uitgaand ? r.ward_uri : r.follower_uri,
-    object: uitgaand ? (r.target_uri || '') : r.ward_uri,
-    'shaer:direction': uitgaand ? 'outgoing' : 'incoming',
-    'shaer:ward': r.ward_uri,
-    'shaer:target': uitgaand ? (r.target_uri || undefined) : undefined,
-    'shaer:targetHandle': uitgaand ? (r.target_handle || undefined) : undefined,
-    'shaer:follower': uitgaand ? undefined : r.follower_uri,
-    'shaer:followerHandle': uitgaand ? undefined : (r.follower_handle || undefined),
-    'shaer:quorum': 'all',
-    'shaer:approvals': stemmen.filter((x) => x.decision === 'approve').length,
-    'shaer:guardianCount': guardianCount || undefined,
-    'shaer:myVote': stemmen.some((x) => x.guardian_uri === me),
-    published: r.created_at,
-  };
-}
-
-/** De openstaande reviews van een guardian, per richting. */
-export function listReviewsByDirection(guardianSlug, direction) {
-  return listReviews(guardianSlug).filter((r) => (r.direction === 'outgoing' ? 'outgoing' : 'incoming') === direction);
-}
-export function getReview(guardianSlug, id) { return rstmts().get.get(guardianSlug, id); }
-export function listReviews(guardianSlug) { return rstmts().bySlug.all(guardianSlug); }
-export function removeReview(guardianSlug, id) { rstmts().del.run(guardianSlug, id); }
-
-export default {
-  recordPending, getPending, listForWard, decide, remove,
-  recordReview, getReview, listReviews, removeReview,
-  listReviewsByDirection, reviewQueueItem,
-};
Index: src/services/guardianship/gated.js
===================================================================
--- src/services/guardianship/gated.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,483 +1,0 @@
-/**
- * Guardianship (FEP-633c §5.6): gated settings the guardians decide together.
- *
- * The point of this file is that it works when the guardians are NOT on the
- * ward's server, which is the ordinary case: a child on the family instance, a
- * grandparent on theirs. A guardian proposes with an `Offer` of a
- * `shaer:GatedSetting` addressed to the ward's server; the other guardians
- * answer; the ward's server tallies and enforces, because it is the one that
- * serves the feed.
- *
- * The tally is a §3.5 decision: a snapshotted set, a threshold (strict
- * majority), a window. A setting is reversible (a permission granted can be
- * withdrawn), so it settles as a race to the threshold and fails closed.
- */
-import db from '../../config/database.js';
-import { listGuardians } from './relations.js';
-import * as availability from './availability.js';
-
-/** The window a gated-setting decision stays open. Reversible, so a day. */
-export const GATED_WINDOW_MS = 24 * 60 * 60 * 1000;
-
-/** Strict majority of the set: 1 of 1, 2 of 2, 2 of 3, 3 of 4. */
-export function thresholdFor(setSize) {
-  return Math.floor(setSize / 2) + 1;
-}
-
-/**
- * Tally one decision. Pure, so the rule can be tested without a database.
- *
- * @param {Array<{guardian_uri: string, value: number|boolean}>} votes
- * @param {string[]} guardianSet  the guardians at the moment the decision opened
- * @param {number} ageMs          how long the decision has been open
- * @returns {{state: 'settled'|'open'|'expired', value?: boolean}}
- */
-export function tallyGatedSetting(votes, guardianSet, ageMs, windowMs = GATED_WINDOW_MS) {
-  const set = new Set((guardianSet || []).filter(Boolean));
-  if (!set.size) return { state: 'expired' };            // nobody may decide
-  const need = thresholdFor(set.size);
-  // Only answers from the snapshotted set count, one per guardian.
-  const seen = new Map();
-  for (const v of (votes || [])) {
-    if (!set.has(v.guardian_uri)) continue;
-    seen.set(v.guardian_uri, v.value === true || v.value === 1);
-  }
-  const yes = [...seen.values()].filter(Boolean).length;
-  const no = seen.size - yes;
-  // Race to the threshold, in both directions: settle the moment it is reached,
-  // and give up the moment it can no longer be reached.
-  if (yes >= need) return { state: 'settled', value: true };
-  if (no >= need) return { state: 'settled', value: false };
-  const undecided = set.size - seen.size;
-  if (yes + undecided < need && no + undecided < need) return { state: 'expired' };
-  if (ageMs >= windowMs) return { state: 'expired' };    // fails closed
-  return { state: 'open' };
-}
-
-/** The column a feature maps onto. Unknown features are refused, not guessed. */
-const FEATURES = {
-  'shaer:externalEmbeds': 'external_embeds',
-  'shaer:externalPlayback': 'external_playback',
-  'shaer:externalThreads': 'external_threads',
-  'shaer:images': 'gate_images',
-  'shaer:messages': 'gate_messages',
-  'shaer:compose': 'gate_compose',
-  'shaer:replies': 'gate_replies',
-  'shaer:music': 'gate_music',
-  'shaer:quoteCards': 'gate_quote_cards',
-  'shaer:customEmoji': 'gate_custom_emoji',
-  'shaer:accountMove': 'gate_account_move',
-  'shaer:following': 'gate_following',
-};
-/**
- * De gates die deze Klonkt kent, met hun SOORT.
- *
- * Wat gated wordt is een ontwerpkeuze van de implementatie: de FEP levert het
- * mechanisme (voorstel, tally, settle) en een paar voorbeelden, niet de lijst.
- * Deze catalogus is die lijst, op een plek. Een gate erbij hoort een regel data
- * te zijn en geen nieuw stuk scherm.
- *
- * `kind` is niet decoratief. De gates verschillen in hoe ze werken en dat mag
- * een guardian niet hoeven raden:
- *
- *   setting     een stand, aan of uit, terug te draaien
- *   perRequest  geen stand maar een stroom beslissingen (5.3 volgverzoeken)
- *   handover    draagt gezag OVER; onomkeerbaar zodra de ward hem gebruikt
- *
- * `needs` is de trap uit shaer-ahy: zien < afspelen. Je kunt niet afspelen wat
- * je niet mag zien, dus dat tweede is pas te bewegen als het eerste openstaat.
- */
-export const GATE_CATALOGUE = [
-  // Werkend: er is een kolom, de tally kan erover beslissen en de server dwingt
-  // hem af bij het serveren (of, voor compose/messages/move, bij het INNEMEN:
-  // wat de ward niet mag versturen wordt aan de outbox geweigerd).
-  { feature: 'shaer:externalEmbeds', kind: 'setting', reversible: true },
-  { feature: 'shaer:externalPlayback', kind: 'setting', reversible: true, needs: 'shaer:externalEmbeds' },
-  // Sinds 8-8 ("maak ze allemaal functioneel", Bart): de hele setting-familie
-  // schakelt echt. De bead-nummers blijven staan, want elk van deze heeft nog
-  // een app-kant (wat de UI toont als de poort dicht is) en die woont daar.
-  { feature: 'shaer:externalThreads', kind: 'setting', reversible: true, bead: 'shaer-9y2' },
-  { feature: 'shaer:images', kind: 'setting', reversible: true, bead: 'shaer-6p5' },
-  { feature: 'shaer:messages', kind: 'setting', reversible: true, bead: 'shaer-3ow' },
-  { feature: 'shaer:compose', kind: 'setting', reversible: true, bead: 'shaer-qgev' },
-  // MEEDOEN AAN EEN GESPREK IS OOK IETS (Bart, 8-8). Dit stond hier bewust niet:
-  // een antwoord gold als meedoen en niet als eigen podium, dus compose liet het
-  // door. Bart heeft dat teruggedraaid -- wie mag antwoorden staat los van wie
-  // mag posten, en het hoort een eigen poort te zijn die je kunt zien.
-  //
-  // Los van compose en niet eronder: je kunt willen dat een kind wel meepraat
-  // maar geen eigen podium heeft, en ook precies andersom.
-  { feature: 'shaer:replies', kind: 'setting', reversible: true, bead: 'shaer-r4c' },
-  { feature: 'shaer:music', kind: 'setting', reversible: true, bead: 'shaer-rmz' },
-  { feature: 'shaer:quoteCards', kind: 'setting', reversible: true, bead: 'shaer-mls' },
-  { feature: 'shaer:customEmoji', kind: 'setting', reversible: true, bead: 'shaer-ytw' },
-  { feature: 'shaer:accountMove', kind: 'setting', reversible: true, bead: 'shaer-tge' },
-  // Wie de ward mag VOLGEN, en wie de ward mag volgen: twee poorten, want twee
-  // vragen. Ze stonden hier als één rij, en dan telt het paneel de ene richting
-  // en zwijgt over de andere -- een guardian ziet "follows: 3 wachtend" en weet
-  // niet of er drie vreemden bij zijn kind willen of dat zijn kind drie keer
-  // heeft gevraagd of het iemand mag volgen. Dat zijn niet dezelfde zorg.
-  //
-  // Inkomend is vast: §5.3 EIST dat een Follow naar een ward langs de guardians
-  // gaat, dus die staat aan en blijft aanstaan. Tonen mag, verzetten niet.
-  // fixedValue false: de poort staat DICHT en blijft dicht -- een Follow naar
-  // een ward gaat altijd langs de guardians. Dezelfde polariteit als de rest
-  // van de familie, waar `value` "mag het zonder tussenkomst?" betekent.
-  { feature: 'shaer:follows', kind: 'perRequest', reversible: true, fixed: true, fixedValue: false },
-  // Uitgaand is verstelbaar, en dat verschil is opzet. De FEP zegt over deze
-  // richting niets: §5.3 gaat alleen over een Follow die op een ward AF komt.
-  // Wat je verder gated is expliciet aan de implementatie gelaten, dus dit is
-  // onze keuze en niet die van de spec -- en dan hoort hij ook echt te kunnen
-  // worden losgelaten, want een kind dat ouder wordt hoort niet eeuwig te
-  // blijven vragen wie het mag volgen (shaer-p729, shaer-yeo5).
-  { feature: 'shaer:following', kind: 'perRequest', reversible: true, bead: 'shaer-p729' },
-
-  // GEPLAND, en dat is bij deze twee geen achterstand maar een besluit.
-  //
-  // publicProfile is niet een veld dat je wegfiltert: het is het hele publieke
-  // web-oppervlak van een site (de Krant, de AP-objecten, de scrape-vraag van
-  // shaer-hj0). Dat dichtzetten zonder dat ontwerp is een half slot, en een
-  // half slot leest als een heel slot -- gevaarlijker dan geen.
-  //
-  // available: false is geen detail. featureColumn() kent deze naam niet, dus
-  // een voorstel strandt op unknown_feature, en de rij leest als "hier is nog
-  // niets van", niet als een gesloten poort.
-  { feature: 'shaer:publicProfile', kind: 'setting', reversible: true, available: false, bead: 'shaer-hj0' },
-  // De enige die gezag OVERDRAAGT, en daarmee de enige die niet terug te draaien
-  // is zodra het kind hem gebruikt (shaer-90v). Telt met de lapse-vorm: volle
-  // set, volle venster. Die vorm hoort daar beslist te worden, niet hier
-  // geimproviseerd: een verkeerd gemaakte onafhankelijkheid is een kind zonder
-  // vangnet.
-  { feature: 'shaer:independence', kind: 'handover', reversible: false, available: false, bead: 'shaer-90v' },
-];
-
-/**
- * De gates van een ward als rijen voor het paneel. Puur, zodat de regels
- * getoetst kunnen worden zonder database of scherm.
- *
- * @param settings       {feature: true|false|null} -- null is ONBEKEND, niet uit
- * @param guardianCount  aantal guardians, of null als we het niet weten
- * @param proposals      [{feature, value, status}] lopende voorstellen
- * @param waiting        {feature: aantal} wat er per gate op een besluit wacht
- */
-export function gateRows({ settings = {}, guardianCount = null, proposals = [], waiting = {}, requested = {} } = {}) {
-  return GATE_CATALOGUE.map((g) => {
-    // Een stand kan drie dingen zijn: beslist-aan, beslist-uit, of de standaard
-    // omdat er nooit iets besloten is. Dat derde als "uit" tonen zou een besluit
-    // suggereren dat niemand nam.
-    const raw = Object.prototype.hasOwnProperty.call(settings, g.feature) ? settings[g.feature] : null;
-    let beslist = raw && typeof raw === 'object' ? !!raw.decided : (raw === true || raw === false);
-    let value = raw && typeof raw === 'object' ? raw.value : raw;
-    // Een VASTE poort heeft geen kolom om een stand in te bewaren, want er valt
-    // niets te bewaren: hij staat zoals de spec hem zet. Zonder dit viel hij
-    // door naar "nooit besloten" en las het paneel eeuwig "onbekend" -- wat een
-    // vraag suggereert die er niet is. §5.3 EIST dat een Follow naar een ward
-    // langs de guardians gaat, dus dat is beslist, alleen niet door ons.
-    if (g.fixed) { value = !!g.fixedValue; beslist = true; }
-    // De trap: het bovenliggende moet OPEN staan. Onbekend telt niet als dicht --
-    // bij een ward elders kennen we de stand niet, en verbergen betekende daar
-    // ooit dat een voorstel nooit geopend kon worden.
-    const bovenliggend = settings[g.needs];
-    const bovenWaarde = bovenliggend && typeof bovenliggend === 'object' ? bovenliggend.value : bovenliggend;
-    const bovenBeslist = bovenliggend && typeof bovenliggend === 'object' ? bovenliggend.decided : (bovenWaarde === true || bovenWaarde === false);
-    // Alleen dichthouden als we ZEKER weten dat het bovenliggende uit staat.
-    const blockedBy = (g.needs && bovenBeslist && bovenWaarde === false) ? g.needs : null;
-    return {
-      feature: g.feature,
-      kind: g.kind,
-      reversible: !!g.reversible,
-      value,
-      decided: beslist,
-      // Vast staat vast: tonen mag, verzetten niet.
-      // Wat er niet is, valt niet te verzetten. Een knop die op unknown_feature
-      // strandt is erger dan geen knop.
-      available: g.available !== false,
-      // Vast is iets anders dan geblokkeerd of afwezig, en alle drie maken ze
-      // `adjustable` false. Een client die alleen dat ziet weet niet WAAROM er
-      // geen knop is; met dit veld kan hij "altijd" zeggen in plaats van een
-      // stand te tonen alsof er ooit nog iets aan verandert.
-      fixed: !!g.fixed,
-      adjustable: g.available !== false && !g.fixed && !blockedBy,
-      blockedBy: blockedBy || undefined,
-      // Zonder bekend aantal guardians GEEN drempel verzinnen. Nul of een gok
-      // leest als een feit, en dit is precies waar een guardian op afgaat.
-      threshold: (guardianCount && guardianCount > 0)
-        ? { need: thresholdFor(guardianCount), of: guardianCount } : null,
-      proposal: proposals.find((p) => p.feature === g.feature) || undefined,
-      waiting: waiting[g.feature] || undefined,
-      // Het kind vroeg hier zelf om (shaer-8ru). Apart van `waiting`: drie
-      // onbekenden die je kind willen volgen is iets anders dan je kind dat
-      // een keer vraagt of muziek aan mag, en een gedeeld getal maakt daar
-      // hetzelfde van.
-      requested: requested[g.feature] || undefined,
-    };
-  });
-}
-
-export function featureColumn(feature) {
-  return Object.prototype.hasOwnProperty.call(FEATURES, feature) ? FEATURES[feature] : null;
-}
-
-/**
- * Record one guardian's answer and settle if the threshold is now reached.
- * Returns the tally state so a caller can report it.
- */
-export function recordGatedVote(slug, feature, guardianUri, value) {
-  const column = featureColumn(feature);
-  if (!column) return { state: 'expired', error: 'unknown_feature' };
-  const all = listGuardians(slug).map((g) => g.other_uri);
-  if (!all.includes(guardianUri)) return { state: 'expired', error: 'not_a_guardian' };
-  // A vote is an answer, whatever it is a vote on (§3.6): the voter is
-  // restored first, so it always counts itself back into the set below.
-  availability.oneAnswer(guardianUri, Date.now());
-  // §3.5: the threshold runs over the AVAILABLE set. Membership is checked
-  // against the full list above: any guardian may answer, and answering is
-  // exactly what brings it back in.
-  const guardians = availability.availableSet(slug, all, Date.now());
-
-  // The window opens with the first answer, and a stale decision starts over:
-  // a proposal from last month should not silently count toward today's.
-  const existing = db.prepare('SELECT MIN(opened_at) AS opened FROM ap_gated_votes WHERE slug = ? AND feature = ?')
-    .get(slug, feature);
-  let openedAt = existing && existing.opened ? new Date(existing.opened).getTime() : Date.now();
-  if (Number.isNaN(openedAt) || Date.now() - openedAt >= GATED_WINDOW_MS) {
-    db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
-    openedAt = Date.now();
-  }
-  db.prepare(`INSERT INTO ap_gated_votes (slug, feature, guardian_uri, value, opened_at)
-              VALUES (?,?,?,?,?)
-              ON CONFLICT(slug, feature, guardian_uri) DO UPDATE SET value = excluded.value`)
-    .run(slug, feature, guardianUri, value ? 1 : 0, new Date(openedAt).toISOString());
-
-  const votes = db.prepare('SELECT guardian_uri, value FROM ap_gated_votes WHERE slug = ? AND feature = ?')
-    .all(slug, feature);
-  const result = tallyGatedSetting(votes, guardians, Date.now() - openedAt);
-  if (result.state === 'settled') {
-    db.prepare(`UPDATE sites SET ${column} = ? WHERE slug = ?`).run(result.value ? 1 : 0, slug);
-    db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
-  } else if (result.state === 'expired') {
-    db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
-  }
-  return { ...result, need: thresholdFor(guardians.length), of: guardians.length };
-}
-
-/**
- * Wat er blijft hangen als deze gate opengaat (shaer-nf9).
- *
- * BARTS ZIN KLOPT NIET LETTERLIJK, en dat is precies waarom dit hier staat. "Een
- * geopende poort gaat niet meer dicht" is onwaar over de INSTELLING -- shaer-ahy
- * eist het tegendeel en de code doet het: een voorstel draagt true of false. Maar
- * het GEVOLG is wel onomkeerbaar. De poort gaat later weer dicht; wat er in de
- * tussentijd doorheen kwam komt niet terug. Een kind dat iets gezien heeft, heeft
- * het gezien.
- *
- * Dat verschil moet in de tekst, om twee redenen. Een waarschuwing die aantoonbaar
- * onwaar is neemt de rest van het scherm mee in zijn val zodra iemand het merkt.
- * En de ware versie is ZWAARDER: "je kunt dit terugdraaien maar niet ongedaan
- * maken" zet je harder stil dan een verbod dat niet blijkt te kloppen.
- *
- * ONBEKEND KRIJGT DE ZWAARSTE TEKST. Een mede-guardian elders kan een feature
- * voorstellen die onze catalogus niet kent, en dan weten wij niet wat het doet.
- * Bij twijfel waarschuwen we zwaarder, niet lichter -- de faalstand die hier pijn
- * doet is een guardian die iets doorlaat omdat het scherm er licht over deed.
- */
-export function gateConsequence(feature) {
-  const g = GATE_CATALOGUE.find((x) => x.feature === feature);
-  if (!g) return 'unknown';
-  return g.reversible === false ? 'irreversible' : 'reversible';
-}
-
-/**
- * Zou het antwoord van deze guardian het besluit AFMAKEN (shaer-8vt)?
- *
- * De telling is een race naar de drempel: zodra het aantal gehaald is, is het
- * gevallen. Bij 2 van 3 is de tweede ja dus meteen de beslissing, en bij een
- * volgverzoek met twee guardians is de EERSTE ja dat al. Wie antwoordt weet dat
- * niet, en het scherm zei het nergens.
- *
- * EEN JA/NEE, GEEN TELLING, en dat is een besluit. Een getal ("1 van 2") reist
- * mee, veroudert onderweg en leest daarna als een feit; de beschikbare set
- * schuift bovendien met 3.6 mee. En hoeveel guardians een kind heeft, en wie er
- * al gestemd heeft, is niet vanzelf iets dat elke mede-guardian hoort te zien.
- * Een waarschuwing veroudert ook, maar hij CLAIMT niets -- en dat scheelt.
- *
- * BIJ TWIJFEL WAARSCHUWEN. De twee fouten zijn niet gelijk: zeggen dat je
- * beslist terwijl dat niet zo is maakt iemand voorzichtiger dan nodig; niets
- * zeggen terwijl hij wel beslist laat hem het onwetend doen.
- */
-export function isDecisive(votes, need) {
-  const v = Number.isFinite(votes) ? votes : 0;
-  const n = Number.isFinite(need) ? need : 1;
-  return (n - v) <= 1;
-}
-
-/** The open decision for a feature, for showing progress ("1 of 2"). */
-export function gatedProgress(slug, feature) {
-  const votes = db.prepare('SELECT guardian_uri, value FROM ap_gated_votes WHERE slug = ? AND feature = ?')
-    .all(slug, feature);
-  // Progress over the available set (§3.5), like the tally itself.
-  const guardians = availability.availableSet(slug, listGuardians(slug).map((g) => g.other_uri), Date.now());
-  return { votes: votes.length, need: thresholdFor(guardians.length), of: guardians.length };
-}
-
-// ── The federated shape (§5.6) ────────────────────────────────────
-// An Offer of a shaer:GatedSetting, answered with Accept/Reject. Parsing lives
-// here so both the inbox and the outbox read it the same way.
-
-/** Read a shaer:GatedSetting object, or null when this is a different Offer. */
-export function parseGatedSetting(object) {
-  if (!object || typeof object !== 'object') return null;
-  const type = Array.isArray(object.type) ? object.type[0] : object.type;
-  if (type !== 'shaer:GatedSetting' && type !== 'GatedSetting') return null;
-  const ward = object['shaer:ward'] || object.ward;
-  const feature = object['shaer:feature'] || object.feature;
-  const value = object['shaer:value'] !== undefined ? object['shaer:value'] : object.value;
-  if (typeof ward !== 'string' || typeof feature !== 'string') return null;
-  return { ward, feature, value: value === true || value === 1 || value === 'true' };
-}
-
-/** Build the Offer a guardian sends to the ward's server. */
-export function buildGatedOffer(offerId, actor, ward, feature, value) {
-  return {
-    id: offerId,
-    type: 'Offer',
-    actor,
-    to: [ward],
-    object: {
-      type: 'shaer:GatedSetting',
-      'shaer:ward': ward,
-      'shaer:feature': feature,
-      'shaer:value': !!value,
-    },
-  };
-}
-
-// ── The guardian-side copy (the missing leg of §5.6) ──────────────
-// A proposal addressed to the ward's server reaches only the proposer and the
-// ward. The other guardians never learn it exists, so a threshold of two can
-// never be met and every proposal expires unanswered. The ward's server
-// therefore FORWARDS it, exactly as it forwards a gated follow (§5.3): each
-// guardian stores a copy it can answer, and the answer travels back to the
-// ward, which tallies.
-
-let _rs = null;
-function rstmts() {
-  if (!_rs) {
-    _rs = {
-      ins: db.prepare(`INSERT INTO ap_gated_reviews (id, guardian_slug, ward_uri, ward_inbox, proposer, feature, value, decisive)
-                       VALUES (?,?,?,?,?,?,?,?)
-                       ON CONFLICT(guardian_slug, id) DO UPDATE SET value = excluded.value, ward_inbox = excluded.ward_inbox, decisive = excluded.decisive`),
-      get: db.prepare('SELECT * FROM ap_gated_reviews WHERE guardian_slug = ? AND id = ?'),
-      bySlug: db.prepare('SELECT * FROM ap_gated_reviews WHERE guardian_slug = ? ORDER BY created_at DESC'),
-      del: db.prepare('DELETE FROM ap_gated_reviews WHERE guardian_slug = ? AND id = ?'),
-      delAll: db.prepare('DELETE FROM ap_gated_reviews WHERE id = ?'),
-    };
-  }
-  return _rs;
-}
-
-export function recordGatedReview(guardianSlug, r) {
-  // decisive ontbreekt bij een oudere server -> 1, want bij twijfel waarschuwen.
-  rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.proposer || null, r.feature, r.value ? 1 : 0, r.decisive === false ? 0 : 1);
-  return rstmts().get.get(guardianSlug, r.id);
-}
-export function getGatedReview(guardianSlug, id) { return rstmts().get.get(guardianSlug, id); }
-export function listGatedReviews(guardianSlug) { return rstmts().bySlug.all(guardianSlug); }
-export function removeGatedReview(guardianSlug, id) { rstmts().del.run(guardianSlug, id); }
-/** Drop every guardian's copy once the decision has settled or lapsed. */
-export function clearGatedReviews(id) { rstmts().delAll.run(id); }
-
-export function rememberGatedOffer(offerId, slug, feature, value, proposer) {
-  try {
-    db.prepare('INSERT OR REPLACE INTO ap_gated_offers (offer_id, slug, feature, value, proposer) VALUES (?,?,?,?,?)')
-      .run(offerId, slug, feature, value ? 1 : 0, proposer || null);
-  } catch { /* non-fatal */ }
-}
-
-export function recallGatedOffer(offerId) {
-  try { return db.prepare('SELECT * FROM ap_gated_offers WHERE offer_id = ?').get(offerId) || null; }
-  catch { return null; }
-}
-
-// ── The proposer's own record (5.6) ───────────────────────────────
-// "Where did my proposal go?" had no answer: the status was a button caption
-// that did not survive a refresh. The ward's server tallies elsewhere, so the
-// proposer keeps its own row and the ward's server ANSWERS the Offer when the
-// decision settles: Accept when it settled on the proposed value, Reject when
-// it settled on the opposite. An open row past the window renders as expired,
-// because an expired decision settles on nothing and nobody writes home.
-
-export function recordSent(offerId, guardianSlug, wardUri, feature, value) {
-  try {
-    db.prepare(`INSERT OR REPLACE INTO ap_gated_sent (offer_id, guardian_slug, ward_uri, feature, value)
-                VALUES (?,?,?,?,?)`).run(offerId, guardianSlug, wardUri, feature, value ? 1 : 0);
-  } catch { /* non-fatal */ }
-}
-
-export function recallSent(offerId) {
-  try { return db.prepare('SELECT * FROM ap_gated_sent WHERE offer_id = ?').get(offerId) || null; }
-  catch { return null; }
-}
-
-/**
- * De stand van een gate zoals DEZE guardian hem kent.
- *
- * Er zijn geen lokale accounts: elke ward woont op een andere server, dus de
- * kolom op onze eigen sites-tabel is voor een ward altijd leeg. Wat een guardian
- * wel heeft is de UITSLAG van besluiten -- een geaccepteerd voorstel met waarde
- * true betekent dat de poort openging.
- *
- * Geeft { value, decided }:
- *   decided true   we hebben een aangenomen besluit gezien; value is die waarde
- *   decided false  we hebben er geen; value is de standaard voor een ward (uit)
- *
- * Dat verschil hoort zichtbaar te blijven. "Uit" en "voor zover wij weten uit"
- * zijn niet hetzelfde, en het tweede is wat we meestal hebben.
- *
- * BEKEND GAT: dit ziet alleen onze EIGEN voorstellen. Antwoordde je op dat van
- * een mede-guardian, dan komt de uitslag wel binnen (gated_outcome) maar wordt
- * hij niet bewaard -- handshake.js legt alleen vast voor sent-rijen die van ons
- * zijn. Een gate die een ander heeft geopend leest hier dus als "uit". Dat is de
- * onveilige kant en het hoort gerepareerd te worden.
- */
-export function knownSetting(guardianSlug, wardUri, feature) {
-  try {
-    const r = db.prepare(`SELECT value FROM ap_gated_sent
-                           WHERE guardian_slug = ? AND ward_uri = ? AND feature = ? AND status = 'accepted'
-                           ORDER BY created_at DESC LIMIT 1`).get(guardianSlug, wardUri, feature);
-    if (r) return { value: !!r.value, decided: true };
-  } catch { /* val terug op de standaard */ }
-  return { value: false, decided: false };
-}
-
-export function settleSent(offerId, outcome) {
-  try { db.prepare('UPDATE ap_gated_sent SET status = ? WHERE offer_id = ?').run(outcome, offerId); } catch { /* non-fatal */ }
-}
-
-/** The latest proposal per feature this guardian sent to this ward. */
-export function listSent(guardianSlug, wardUri) {
-  try {
-    return db.prepare(`SELECT * FROM ap_gated_sent WHERE guardian_slug = ? AND ward_uri = ?
-                       GROUP BY feature HAVING MAX(created_at) ORDER BY created_at DESC`).all(guardianSlug, wardUri);
-  } catch { return []; }
-}
-
-/**
- * What a sent row means on a screen. Pure, so the rule is testable: an answer
- * wins, and silence past the window is not "still running", it is over.
- */
-export function sentStatus(row, now) {
-  if (!row) return null;
-  if (row.status === 'accepted' || row.status === 'rejected') return row.status;
-  const opened = new Date(String(row.created_at).includes('T') ? row.created_at : `${row.created_at}Z`.replace(' ', 'T')).getTime();
-  if (Number.isFinite(opened) && now - opened >= GATED_WINDOW_MS) return 'expired';
-  return 'open';
-}
-
-export default {
-  GATE_CATALOGUE, gateRows, knownSetting,
-  tallyGatedSetting, thresholdFor, featureColumn, recordGatedVote, gatedProgress, gateConsequence, isDecisive, GATED_WINDOW_MS,
-  parseGatedSetting, buildGatedOffer, rememberGatedOffer, recallGatedOffer,
-  recordGatedReview, getGatedReview, listGatedReviews, removeGatedReview, clearGatedReviews,
-  recordSent, recallSent, settleSent, listSent, sentStatus,
-};
Index: src/services/guardianship/gatereq.js
===================================================================
--- src/services/guardianship/gatereq.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,99 +1,0 @@
-/**
- * Een kind dat zelf om een poort vraagt (shaer-8ru, Barts opdracht 8-8).
- *
- * Tot nu toe liep alles over de guardians: zij zien de catalogus, zij stellen
- * voor, zij tellen. Het kind liep tegen een dichte deur en had geen woorden.
- * Dit is die woorden -- niet meer dan dat.
- *
- * EEN VRAAG IS GEEN STEM, en dat is de hele grens. De ward stelt niet voor en
- * stemt niet mee; het verzoek landt bij zijn guardians als iets om over te
- * beslissen, en pas als EEN GUARDIAN het oppakt wordt het een voorstel dat
- * langs de gewone tally gaat. Zou een verzoek zelf een voorstel zijn, dan kon
- * een kind zijn eigen poort openen door hard genoeg te vragen.
- *
- * NIET DE REDDINGSBOEI, en dat verschil moet scherp blijven. Een hulpvraag is
- * een noodgeval en gaat door elke dichte deur heen. Dit is een wens. Ze door
- * elkaar laten lopen zou de boei devalueren tot "het kind wil iets", en dan
- * kijkt er op een dag niemand meer op als hij afgaat.
- *
- * GEEN VRIJE TEKST. Een verzoek draagt alleen de naam van de feature. Dat is
- * niet gierig maar precies de reden dat hij langs de messages-poort MAG: een
- * kind met berichten dicht kan nog steeds om iets vragen, zonder dat daarmee
- * een kanaal ontstaat om omheen die poort te praten. Wil het kind uitleggen
- * waarom, dan is dat een gesprek, en gesprekken hebben hun eigen poort.
- */
-
-import db from '../../config/database.js';
-
-let _s = null;
-function stmts() {
-  if (!_s) {
-    _s = {
-      ins: db.prepare(`INSERT OR IGNORE INTO ap_gate_requests (slug, ward_uri, feature, note_uri)
-                       VALUES (?,?,?,?)`),
-      bySlug: db.prepare(`SELECT * FROM ap_gate_requests WHERE slug = ? AND handled_at IS NULL
-                          ORDER BY created_at DESC`),
-      handle: db.prepare(`UPDATE ap_gate_requests SET handled_at = CURRENT_TIMESTAMP
-                          WHERE slug = ? AND ward_uri = ? AND feature = ? AND handled_at IS NULL`),
-    };
-  }
-  return _s;
-}
-
-/** Leg vast dat dit kind om deze poort vroeg. Nooit dragend: een verzoek dat
- *  niet opgeslagen kan worden mag geen inkomend bericht laten stranden. */
-export function record(slug, wardUri, feature, noteUri = null) {
-  if (!slug || !wardUri || !feature) return;
-  try { stmts().ins.run(slug, wardUri, feature, noteUri); } catch { /* nooit dragend */ }
-}
-
-/** De openstaande verzoeken van de kinderen van deze guardian. */
-export function listOpen(slug) {
-  try { return stmts().bySlug.all(slug); } catch { return []; }
-}
-
-/**
- * Afgehandeld: er is een voorstel van gemaakt, of een guardian legde hem weg.
- *
- * Verdwijnt niet uit de tabel. Er wordt niets herschreven, er wordt toegevoegd
- * -- zelfde regel als bij de hulpvraag, en om dezelfde reden: wat een kind
- * gevraagd heeft hoort terug te vinden te zijn, ook als het antwoord nee was.
- */
-export function markHandled(slug, wardUri, feature) {
-  try { stmts().handle.run(slug, wardUri, feature); } catch { /* nooit dragend */ }
-}
-
-/**
- * Hoeveel verzoeken er per feature openstaan voor dit kind, voor de
- * waiting-kolom van het paneel. Zo staat de vraag bij de poort waar hij over
- * gaat, en niet in een aparte lijst die je apart moet openen.
- */
-export function waitingFor(slug, wardUri) {
-  const uit = {};
-  for (const r of listOpen(slug)) {
-    if (r.ward_uri !== wardUri) continue;
-    uit[r.feature] = (uit[r.feature] || 0) + 1;
-  }
-  return uit;
-}
-
-/** Het verzoek als bericht. Bewust dezelfde vorm als de zwaai en de
- *  hulpmarkering: een gewone directe note met een shaer:-markering, zodat hij
- *  over de bestaande bezorging reist en niet over een eigen kanaal. */
-export function requestNote({ id, me, feature, to }) {
-  return {
-    id, type: 'Note', attributedTo: me, to,
-    'shaer:gateRequest': feature,
-    // Vaste tekst, geen invoer van het kind: zie de kop over vrije tekst.
-    content: '<p>Mag dit aan?</p>',
-  };
-}
-
-/** Leest een binnengekomen note als poortverzoek, of null als hij er geen is. */
-export function parseRequest(object) {
-  if (!object || typeof object !== 'object') return null;
-  const f = object['shaer:gateRequest'] || object.gateRequest;
-  return (typeof f === 'string' && f) ? { feature: f } : null;
-}
-
-export default { record, listOpen, markHandled, waitingFor, requestNote, parseRequest };
Index: src/services/guardianship/handshake.js
===================================================================
--- src/services/guardianship/handshake.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,635 +1,0 @@
-/**
- * Guardianship (FEP-633c §3) — the adoption handshake, multi-party and
- * distributed across instances.
- *
- * The candidate Offers a Relationship{subject: ward, object: candidate},
- * addressed to the ward AND every existing guardian of the ward. Each party
- * (ward, existing guardians, and finally the candidate) Accepts, addressed to
- * all the others, so every instance's copy of the tally converges. The
- * candidate's Accept is the LAST one and carries the escalation handle in
- * `result`: that return is the atomic commit (§3.1.3). Only then does the
- * ward gain the guardian in shaer:guardians and the guardian gain the ward.
- * A single Reject from any party voids the offer (§3.2).
- *
- * The state machine lives in offers.js (a faithful port of the Shaer test
- * daemon); this module wires it onto Klonkt's C2S/S2S plumbing. AP helpers
- * arrive once via wireHandshake(deps); nothing here imports ActivityPubService.
- */
-import { isGuardianRelationship, GUARDIAN_RELATIONSHIP_COMPACT, carriesGuardians } from './context.js';
-import * as offers from './offers.js';
-import * as relations from './relations.js';
-import * as gated from './gated.js';
-import * as availability from './availability.js';
-
-let deps = null;
-export function wireHandshake(d) { deps = d; }
-
-const idOf = (v) => (typeof v === 'string' ? v : (v && typeof v === 'object' && typeof v.id === 'string' ? v.id : null));
-const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string');
-
-/**
- * FEP-633c §3.2/§3.3 — ending a guardianship.
- *
- * "After commit, either side MAY end the relationship with `Undo` of the
- * `Relationship`. An `Undo` from a guardian, or from the ward co-signed by an
- * existing guardian, removes the guardian from `shaer:guardians`."
- *
- * §3.3 bounds it: this is how ONE guardian goes while others remain. Removing
- * the last one empties `shaer:guardians` and that is emancipation (§3.4), which
- * has its own flow and is explicitly not a single party's call. So an Undo that
- * would leave a ward with nobody is refused here rather than quietly performed.
- */
-export function parseUndoRelationship(activity) {
-  const type = Array.isArray(activity && activity.type) ? activity.type[0] : (activity && activity.type);
-  if (type !== 'Undo') return null;
-  return parseRelationship(activity && activity.object);
-}
-
-/**
- * De overgebleven guardians opnieuw vertellen of ZIJ nu de doorslag geven
- * (shaer-8vt, Barts correctie 8-8).
- *
- * "Doorslaggevend" is geen eigenschap van een moment maar van een STAND: zodra
- * er nog een stem nodig is, is iedereen die nog moet antwoorden het. Eenmalig
- * berekenen bij het doorsturen bevriest een antwoord dat verandert.
- *
- * Nooit dragend: lukt de update niet, dan blijft de oude waarde staan. Die is
- * dan te voorzichtig of te stil -- en juist daarom staat de FAALSTAND aan de
- * kant van waarschuwen (isDecisive leest onbekend als "ja, jij beslist").
- */
-function herzieDoorslag(site, offerId, gsOffer, laatsteStem) {
-  try {
-    const p = gated.gatedProgress(site.slug, gsOffer.feature);
-    if (!gated.isDecisive(p.votes, p.need)) return;   // nog niets veranderd
-    const me = deps.selfId(site.slug);
-    const gestemd = new Set([gsOffer.proposer, laatsteStem].filter(Boolean));
-    for (const g of relations.listGuardians(site.slug).map((x) => x.other_uri)) {
-      if (gestemd.has(g)) continue;
-      deps.deliverTo(site, g, {
-        id: offerId, type: 'Offer', actor: me, to: [g],
-        object: { type: 'shaer:GatedSetting', 'shaer:ward': me, 'shaer:feature': gsOffer.feature, 'shaer:value': !!gsOffer.value },
-        'shaer:proposer': gsOffer.proposer || undefined,
-        'shaer:decisive': true,
-      }).catch(() => { /* de bezorgwachtrij probeert opnieuw */ });
-    }
-  } catch { /* nooit dragend */ }
-}
-
-/** Parse a Relationship object into {ward, candidate} or null. */
-export function parseRelationship(rel) {
-  if (!rel || typeof rel !== 'object') return null;
-  const type = Array.isArray(rel.type) ? rel.type[0] : rel.type;
-  if (type !== 'Relationship') return null;
-  if (!isGuardianRelationship(String(rel.relationship || ''))) return null;
-  const ward = idOf(rel.subject);
-  const candidate = idOf(rel.object);
-  return ward && candidate ? { ward, candidate } : null;
-}
-
-/**
- * The existing guardians of a ward: local list, or the remote actor's
- * shaer:guardians.
- *
- * Geexporteerd sinds shaer-lgo: de markeerroute had zijn EIGEN afleiding, en
- * die was fout voor precies het geval dat telt (zie routes/guardian.js).
- */
-export async function existingGuardiansOf(wardUri) {
-  const local = deps.localSlug(wardUri);
-  if (local) return relations.listGuardians(local).map((r) => r.other_uri);
-  const doc = await deps.fetchActor(wardUri).catch(() => null);
-  const g = doc && doc['shaer:guardians'];
-  return Array.isArray(g) ? g.filter((x) => typeof x === 'string') : [];
-}
-
-function offerActivity(offerId, ward, candidate, recipients) {
-  return {
-    id: offerId, type: 'Offer', actor: candidate, to: recipients,
-    object: { type: 'Relationship', subject: ward, relationship: GUARDIAN_RELATIONSHIP_COMPACT, object: candidate },
-  };
-}
-
-/** Deliver `activity` to every uri in `recipients` (skipping the local self). */
-async function fanout(site, recipients, activity) {
-  let anyDelivered = false;
-  for (const uri of [...new Set(recipients)]) {
-    const r = await deps.deliverTo(site, uri, activity).catch(() => ({ delivered: false }));
-    if (r && r.delivered !== false) anyDelivered = true;
-  }
-  return anyDelivered;
-}
-
-/**
- * §5.6, the closing of the loop: a settled gated decision answers the Offer
- * that opened it. Accept when it settled on the proposed value, Reject when on
- * the opposite. Without this the proposer's screen can only ever say
- * "waiting", forever, whatever actually happened: the tally lives on the
- * ward's server and nobody else may read it, so the ward's server must speak.
- */
-function answerGatedProposer(site, offerId, r) {
-  const o = gated.recallGatedOffer(offerId);
-  if (!o || !o.proposer) return;
-  const me = deps.selfId(site.slug);
-  if (o.proposer === me) return;   // the ward proposed to itself: nothing to write home
-  const agreed = r.value === !!o.value;
-  deps.deliverTo(site, o.proposer, {
-    id: `${me}#gatedanswer-${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`,
-    type: agreed ? 'Accept' : 'Reject',
-    actor: me, to: [o.proposer], object: offerId,
-  }).catch(() => { /* the delivery queue retries */ });
-}
-
-/** Apply the local side of a commit: the ward writes its guardian, the
- *  candidate writes its ward. Each instance writes only what it hosts.
- *  other_handle is the human @handle for display (from the offer); the FEP
- *  escalation handle (candidate inbox) lives on the offer row, not here. */
-function applyCommitLocally(offer) {
-  const wardSlug = deps.localSlug(offer.ward_uri);
-  const candSlug = deps.localSlug(offer.candidate_uri);
-  if (wardSlug) relations.commitGuardianForWard(wardSlug, offer.candidate_uri, { handle: offer.candidate_handle, offerId: offer.offer_id });
-  if (candSlug) relations.commitWardForGuardian(candSlug, offer.ward_uri, { handle: offer.ward_handle, offerId: offer.offer_id });
-}
-
-/**
- * FEP-633c §4.2 — is this candidate fit to be a guardian at all?
- *
- * A guardian MUST be free of guardians (§1). Checked here and not at the Offer,
- * because guardianship state can change in between: a candidate that was free
- * when it offered may have been adopted before the ward accepted. So the check
- * runs against a freshly dereferenced actor document, at the moment the
- * relationship would become real.
- *
- * Three answers, and the third is not a failure of this check but a failure to
- * perform it:
- *   'ok'          — free of guardians, may serve
- *   'malformed'   — carries shaer:guardians; a teapot (§4)
- *   'unverified'  — the actor could not be read at all
- */
-async function candidateFitness(candidateUri) {
-  // A candidate on this instance needs no dereference: our own tables are the
-  // document, and fresher than anything we could fetch from ourselves. This is
-  // also the co-located case (ward and guardian on one Klonkt), where there is
-  // no network to be unreachable on.
-  const local = deps.localSlug(candidateUri);
-  if (local) return relations.listGuardians(local).length > 0 ? 'malformed' : 'ok';
-
-  const doc = await deps.fetchActor(candidateUri).catch(() => null);
-  if (!doc) return 'unverified';
-  return carriesGuardians(doc) ? 'malformed' : 'ok';
-}
-
-/** Commit this local copy of the offer when the tally is complete (ward +
- *  candidate + ≥1 existing guardian, §3.1.2). The handle is the candidate's
- *  inbox (§6 minimum); the commit is order-independent, so whichever accept
- *  lands last triggers it on every copy. */
-async function maybeCommit(slug, offerId) {
-  const offer = offers.getOffer(slug, offerId);
-  if (!offer || !offers.readyToCommit(offer)) return { done: null, refused: null };
-
-  const fitness = await candidateFitness(offer.candidate_uri);
-
-  // §4.2: unlike the soft skip at delivery (§4.1), this refusal is loud. A
-  // handshake concerns exactly one candidate, so there is no remaining
-  // well-formed target to continue to; committing anyway would leave the ward
-  // counting a guardian whose escalations get dropped. Voiding is all this
-  // function does; saying so on the wire belongs to whoever was acting.
-  if (fitness === 'malformed') {
-    offers.recordReject(slug, offerId, offer.ward_uri);   // voids this copy (§3.2)
-    notify(slug, {
-      kind: 'offer_rejected', offer: offerId,
-      reason: 'not_a_teapot', candidate: offer.candidate_uri,
-    });
-    return { done: null, refused: 'not_a_teapot', offer };
-  }
-
-  // Could not read the candidate: neither commit nor void. Refusing outright
-  // would let a momentary outage destroy a multi-party adoption; committing
-  // would record a guardian nobody checked. The offer stays pending and the
-  // next accept retries.
-  if (fitness === 'unverified') return { done: null, refused: null };
-
-  const done = offers.commit(slug, offerId, `${offer.candidate_uri}/inbox`);
-  if (done) { applyCommitLocally(done); notify(slug, { kind: 'committed', ward: done.ward_uri, guardian: done.candidate_uri }); }
-  return { done, refused: null };
-}
-
-/**
- * End a guardianship from the local guardian's side and let it travel (§3.2).
- *
- * One path for both callers: the button in the Guardian PWA and an `Undo` a
- * Guardian app POSTs to its own outbox. Addressed like the Offer that started
- * it (§3.1.1): the ward, and every other guardian, so no copy is left behind
- * believing the relation still stands.
- */
-export async function endGuardianship(site, wardUri) {
-  const me = deps.selfId(site.slug);
-  if (!relations.getRelation(site.slug, 'guardian', wardUri)) return { status: 404, error: 'not_my_ward' };
-  const set = await existingGuardiansOf(wardUri);
-  const others = set.filter((g) => g !== me);
-  // Only a set we actually read counts as proof. A remote ward whose server is
-  // down reads as an empty set; refusing on that would trap the guardian, and
-  // the ward's server checks again on arrival anyway.
-  if (set.length && others.length === 0) return { status: 409, error: 'would_emancipate' };
-  const recipients = [wardUri, ...others];
-  const undo = {
-    id: `${me}/undo/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`,
-    type: 'Undo', actor: me, to: recipients,
-    object: { type: 'Relationship', subject: wardUri, relationship: GUARDIAN_RELATIONSHIP_COMPACT, object: me },
-  };
-  const delivered = await fanout(site, recipients, undo);
-  relations.removeRelation(site.slug, 'guardian', wardUri);
-  // A ward we host ourselves never receives its own delivery: an inbox on this
-  // machine is not reachable over HTTP from this machine (and should not be).
-  // The commit path has the same shape and solves it the same way — each
-  // instance writes what it hosts (applyCommitLocally).
-  const wardSlug = deps.localSlug(wardUri);
-  if (wardSlug) dropGuardianFromWard(wardSlug, deps.selfId(site.slug));
-  notify(site.slug, { kind: 'guardianship_ended', ward: wardUri, delivered });
-  return { status: 202, delivered, guardiansLeft: others.length };
-}
-
-/**
- * The ward's side of an ended guardianship: drop that guardian, unless doing so
- * would empty the set. §3.3 only permits this while more than one remains;
- * emptying it is emancipation (§3.4) and no single party decides that.
- */
-function dropGuardianFromWard(wardSlug, guardianUri) {
-  const set = relations.listGuardians(wardSlug).map((r) => r.other_uri);
-  if (!set.includes(guardianUri)) return false;   // already gone: an Undo is idempotent
-  if (set.length <= 1) {
-    notify(wardSlug, { kind: 'guardianship_end_refused', guardian: guardianUri, reason: 'would_emancipate' });
-    return false;
-  }
-  relations.removeRelation(wardSlug, 'ward', guardianUri);
-  notify(wardSlug, { kind: 'guardian_left', guardian: guardianUri });
-  return true;
-}
-
-/** The receiving side of that Undo. Returns true when consumed. */
-function applyInboundUndo(site, activity) {
-  const rel = parseUndoRelationship(activity);
-  if (!rel) return false;
-  const me = deps.selfId(site.slug);
-  const actor = idOf(activity.actor);
-  const ward = rel.ward;
-  const guardian = rel.candidate;   // in an Undo the Relationship's object is the leaving guardian
-
-  if (ward === me) {
-    // I am the ward. Only the guardian itself may end its own relation here;
-    // the ward-co-signed variant of §3.2 needs a second signature and is not
-    // built, so it is refused rather than half-honoured.
-    if (actor !== guardian) return false;
-    dropGuardianFromWard(site.slug, guardian);
-    return true;
-  }
-
-  // I am one of the other guardians: nothing of mine changes, but being left
-  // as one of fewer is exactly the kind of thing a guardian should hear about.
-  if (relations.getRelation(site.slug, 'guardian', ward)) {
-    notify(site.slug, { kind: 'coguardian_left', ward, guardian });
-    return true;
-  }
-  return false;
-}
-
-// ── C2S: a LOCAL party acts (PWA, Berichten, or the Shaer app outbox) ──────
-
-/**
- * Handle a guardianship activity POSTed to the local outbox. Returns null when
- * it is not ours, else {status, ...} for the route.
- */
-export async function handleOutbox(site, activity) {
-  const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
-  if (!['Offer', 'Accept', 'Reject', 'Undo'].includes(type)) return null;
-  const me = deps.selfId(site.slug);
-  // One answer restores everything (§3.6): any C2S activity from this actor
-  // is that answer, for every local ward it guards. Runs before anything is
-  // even looked at, so the target of a running lapse cancels it by doing
-  // anything at all — including trying to vote on it.
-  try { availability.oneAnswer(me, Date.now()); } catch { /* never load-bearing */ }
-
-  // ── Undo: a guardian ends its own guardianship (§3.2). Same path as the
-  //    button in the Guardian PWA, so an app and the dashboard cannot drift.
-  if (type === 'Undo') {
-    const rel = parseUndoRelationship(activity);
-    if (!rel) return null;
-    if (rel.candidate !== me) return { status: 403, error: 'not_your_relation' };
-    return endGuardianship(site, rel.ward);
-  }
-
-  // ── Offer: the local site is the guardian-candidate. ───────────────────
-  if (type === 'Offer') {
-    // §3.6.3 over C2S: a guardian here proposes releasing a dormant
-    // co-guardian. A ward we host opens locally; a remote ward gets the
-    // proposal delivered, because the ward's server is the one that tallies
-    // and enforces (the §5.6 line: a guardian next door must not have more
-    // say than one far away).
-    const lp = availability.parseLapse(activity.object);
-    if (lp) {
-      // ONE path (Robins regel, 29-7): the ward's server opens, tallies and
-      // enforces, wherever it lives. A local ward is reached by the same
-      // deliverTo, which loops back into the inbox handler; co-location is a
-      // transport detail and never a shortcut past the decision.
-      const id = `${me}/lapses/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
-      const offer = { id, type: 'Offer', actor: me, to: [lp.ward], object: { type: 'shaer:Lapse', 'shaer:ward': lp.ward, object: lp.target } };
-      const delivered = await fanout(site, [lp.ward], offer);
-      return { status: 202, id, url: id, delivered };
-    }
-    const rel = parseRelationship(activity.object);
-    if (!rel) return null;
-    if (rel.candidate !== me) return { status: 403, error: 'only_the_candidate_offers' };   // fixed initiator (§3.1)
-    if (relations.listGuardians(site.slug).length) return { status: 403, error: 'a_ward_cannot_guard' };  // §1
-    const existing = await existingGuardiansOf(rel.ward);
-    const offerId = `${me}/offers/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
-    offers.start(site.slug, {
-      offerId, ward: rel.ward, candidate: me, existingGuardians: existing,
-      wardHandle: deps.deriveHandle(rel.ward), candidateHandle: deps.deriveHandle(me),
-    });
-    // The Offer IS the candidate's agreement to serve: record it as the
-    // candidate's accept. So a FREE ward commits on its own single accept (no
-    // second guardian to co-approve yet); once it IS a ward, adding another
-    // guardian still needs an existing guardian to co-accept.
-    offers.recordAccept(site.slug, offerId, me);
-    // Addressed to the ward AND every existing guardian (§3.1.1).
-    const recipients = [rel.ward, ...existing];
-    const delivered = await fanout(site, recipients, offerActivity(offerId, rel.ward, me, recipients));
-    notify(site.slug, { kind: 'offer_sent', ward: rel.ward });
-    return { status: 202, id: offerId, url: offerId, delivered };
-  }
-
-  // ── Accept / Reject: the local site is a party answering an offer. ─────
-  const offerId = idOf(activity.object);
-  if (!offerId) return { status: 400, error: 'missing_offer' };
-  // A lapse vote over C2S (§3.6.3): the same Accept/Reject wire the offers
-  // and gated follows use, which is exactly why the Shaer clients need no
-  // new verbs for it.
-  if (availability.getLapse(offerId)) {
-    const r = availability.lapseVote(offerId, me, type === 'Accept', Date.now());
-    if (r && r.error) return { status: r.error === 'not_in_set' ? 403 : 409, error: r.error };
-    return { status: 202, id: offerId, url: offerId, 'shaer:outcome': 'open', 'shaer:accepts': r.accepts, 'shaer:threshold': r.threshold };
-  }
-  let offer = offers.getOffer(site.slug, offerId);
-  if (!offer) return { status: 404, error: 'no_such_offer' };
-  const others = offers.parties(offer).filter((p) => p !== me);
-
-  if (type === 'Reject') {
-    offers.recordReject(site.slug, offerId, me);
-    await fanout(site, others, { id: `${me}/answers/${Date.now().toString(36)}`, type: 'Reject', actor: me, to: others, object: offerId });
-    notify(site.slug, { kind: 'offer_rejected', offer: offerId });
-    return { status: 202, id: offerId, url: offerId };
-  }
-
-  // §1 is flat in BOTH directions. The Offer path above bars a ward from
-  // offering to guard; this is the mirror: an actor that already guards wards
-  // must not become a ward itself. Only the ward's own accept can create that
-  // state, so the candidate and the existing guardians pass through untouched.
-  //
-  // Without it the inconsistency would also be invisible. actorProps() picks
-  // one role with an if/else and would publish shaer:guardians while dropping
-  // shaer:isGuardian, so this account keeps routing its wards' escalations
-  // locally while every remote §4 check reads it as malformed and drops it —
-  // a ward believing it is watched over when it is not, silent on both sides.
-  if (me === offer.ward_uri && relations.listWards(site.slug).length) {
-    return { status: 403, error: 'a_guardian_cannot_be_guarded' };
-  }
-
-  // Accept: record my accept, broadcast it to the other parties, and commit
-  // this copy if the tally is now complete (order-independent, §3.1.3).
-  offers.recordAccept(site.slug, offerId, me);
-  await fanout(site, others, { id: `${me}/answers/${Date.now().toString(36)}`, type: 'Accept', actor: me, to: others, object: offerId });
-  const { done, refused, offer: voided } = await maybeCommit(site.slug, offerId);
-  if (refused) {
-    // §4.2: the refusal travels as a `Reject` of the Offer (§3.2), which an
-    // implementation unaware of §4 still handles correctly. Who is told WHY is
-    // not uniform, and deliberately so.
-    const answer = (to, withReason) => ({
-      id: `${me}/answers/${Date.now().toString(36)}`,
-      type: 'Reject', actor: me, to, object: offerId,
-      ...(withReason ? { 'shaer:notATeapot': true } : {}),
-    });
-    const candidate = voided && voided.candidate_uri;
-    // The ward and its existing guardians MUST learn the reason: they are
-    // parties, the condition is public data (§2.1), and a bare void would
-    // leave a ward believing an adoption completed that did not.
-    const family = others.filter((u) => u !== candidate);
-    if (family.length) await fanout(site, family, answer(family, true));
-    // The candidate gets a BARE Reject. Commit is the last step of §3.1, so a
-    // refusal that names itself technical also discloses that every human
-    // party already accepted and only the protocol objected — which, where a
-    // guardianship is contested, is not theirs to learn. The kind path for an
-    // merely misconfigured candidate is the check on the Offer, before anyone
-    // has consented to anything.
-    if (candidate && others.includes(candidate)) await fanout(site, [candidate], answer([candidate], false));
-    return { status: 202, id: offerId, url: offerId, committed: false, refused };
-  }
-  return { status: 202, id: offerId, url: offerId, committed: !!done, readyToCommit: offers.readyToCommit(offers.getOffer(site.slug, offerId)) };
-}
-
-// ── S2S: a REMOTE party's activity arrives in a local inbox ────────────────
-
-/**
- * Handle an inbound guardianship activity for the local site `site` (the inbox
- * owner). Returns true when consumed.
- */
-export async function handleInbox(site, activity) {
-  const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
-  if (!['Offer', 'Accept', 'Reject', 'Undo'].includes(type)) return false;
-  if (type === 'Undo') return applyInboundUndo(site, activity);
-  const me = deps.selfId(site.slug);
-  const actor = idOf(activity.actor);
-
-  // §5.6: a guardian proposes a gated setting for THIS ward. The ward's server
-  // tallies and enforces, so the decision lands here, not on the proposer.
-  if (type === 'Offer') {
-    const gs = gated.parseGatedSetting(activity.object);
-    if (gs) {
-      const offerId = idOf(activity);
-      // ── I am the WARD: record, tally, and forward to the other guardians.
-      if (gs.ward === me) {
-        gated.rememberGatedOffer(offerId, site.slug, gs.feature, gs.value, actor);
-        // The proposer's Offer carries its own agreement (§3.1's one-step clause).
-        const r = gated.recordGatedVote(site.slug, gs.feature, actor, gs.value);
-        // The forward is the leg that was missing. A proposal addressed to the
-        // ward's server reaches only the proposer and the ward; the other
-        // guardians never learn it exists, so a threshold of two can never be
-        // met and every proposal expires unanswered. The ward's server is the
-        // one that knows the authoritative guardian list, which is exactly why
-        // §5.3 forwards a gated follow from here too.
-        if (r.state === 'open') {
-          for (const g of relations.listGuardians(site.slug).map((x) => x.other_uri)) {
-            if (g === actor) continue;   // the proposer already answered
-            // The forward goes out AS THE WARD, because the ward's key signs
-            // it. Keeping the proposer in `actor` made every receiver answer
-            // 401 signer mismatch, and rightly so: the body claimed one author
-            // and the signature proved another. §5.3 forwards a gated follow
-            // the same way. Who proposed it rides along separately, for the
-            // guardian's screen.
-            // Zou DIT antwoord het besluit afmaken (shaer-8vt)? De telling loopt
-            // hier, op de server van het kind, en nergens anders -- zonder dit
-            // veld kan een guardian elders onmogelijk weten dat hij de doorslag
-            // geeft. Een ja/nee en geen getal: zie isDecisive.
-            const p = gated.gatedProgress(site.slug, gs.feature);
-            deps.deliverTo(site, g, {
-              id: offerId, type: 'Offer', actor: me, to: [g], object: activity.object,
-              'shaer:proposer': actor,
-              'shaer:decisive': gated.isDecisive(p.votes, p.need),
-            }).catch(() => { /* the delivery queue retries */ });
-          }
-        } else {
-          gated.clearGatedReviews(offerId);   // settled at once: nothing left to ask
-          answerGatedProposer(site, offerId, r);
-        }
-        notify(site.slug, { kind: 'gated_setting', feature: gs.feature, value: gs.value, state: r.state });
-        return true;
-      }
-      // ── I am one of the GUARDIANS: the forwarded copy. Store it so this
-      //    guardian can answer; the answer goes back to the ward, which tallies.
-      if (relations.getRelation(site.slug, 'guardian', gs.ward)) {
-        const wardDoc = await deps.fetchActor(gs.ward).catch(() => null);
-        gated.recordGatedReview(site.slug, {
-          id: offerId, wardUri: gs.ward, wardInbox: wardDoc && wardDoc.inbox,
-          // A forward is signed by the ward, so `actor` is the ward; the
-          // guardian who opened it travels in shaer:proposer.
-          proposer: (typeof activity['shaer:proposer'] === 'string' ? activity['shaer:proposer'] : actor),
-          feature: gs.feature, value: gs.value,
-          // Ontbreekt het veld (een oudere server), dan WAARSCHUWEN we: niets
-          // zeggen terwijl je beslist is de gevaarlijke kant (shaer-8vt).
-          decisive: activity['shaer:decisive'] !== false,
-        });
-        notify(site.slug, { kind: 'gated_review', feature: gs.feature, value: gs.value, ward: gs.ward });
-        return true;
-      }
-      return false;   // not our ward, and not a ward we guard
-    }
-    // §3.6.3: a co-guardian proposes releasing a dormant guardian of THIS
-    // ward. The ward's server opens, tallies and (after the full window)
-    // executes, exactly as it does for the gated settings above.
-    const lp = availability.parseLapse(activity.object);
-    if (lp) {
-      if (lp.ward !== me) return false;   // not our ward
-      const id = idOf(activity) || `${me}/lapses/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
-      const r = availability.openLapse({ id, wardSlug: site.slug, wardUri: me, target: lp.target, openedBy: actor, now: Date.now() });
-      if (r.error) {
-        notify(site.slug, { kind: 'lapse_refused', reason: r.error, target: lp.target });
-        return true;   // consumed: the refusal is the answer
-      }
-      // The target is notified like any dormancy marking (§3.6.2): in
-      // protocol (a copy of the Offer, so one answer can cancel it) AND the
-      // §6 handle, which for a committed guardian is its inbox — the same
-      // door this delivery knocks on.
-      deps.deliverTo(site, lp.target, activity).catch(() => { /* best-effort */ });
-      notify(site.slug, { kind: 'lapse_opened', lapse: id, target: lp.target, set: r.set });
-      return true;
-    }
-    const rel = parseRelationship(activity.object);
-    if (!rel) return false;
-    // I must be a party: the ward, or one of the existing guardians in `to`.
-    const recipients = arr(activity.to);
-    const existing = recipients.filter((u) => u !== rel.ward);
-    if (rel.ward !== me && !existing.includes(me)) return false;
-    // §4.2: check the candidate here too, and refuse before anyone accepts.
-    // At this point no party has consented, so saying why discloses nothing
-    // about anyone's position, and a candidate that is merely misconfigured
-    // can find that out and fix it. The commit-time check stays REQUIRED as
-    // the backstop for a candidate whose state changes in between.
-    if (await candidateFitness(rel.candidate) === 'malformed') {
-      notify(site.slug, { kind: 'offer_refused', offer: idOf(activity), reason: 'not_a_teapot', candidate: rel.candidate });
-      await fanout(site, [rel.candidate], {
-        id: `${me}/answers/${Date.now().toString(36)}`,
-        type: 'Reject', actor: me, to: [rel.candidate], object: idOf(activity), 'shaer:notATeapot': true,
-      });
-      return true;
-    }
-    offers.start(site.slug, {
-      offerId: idOf(activity), ward: rel.ward, candidate: rel.candidate, existingGuardians: existing,
-      wardHandle: deps.deriveHandle(rel.ward), candidateHandle: deps.deriveHandle(rel.candidate),
-    });
-    // The Offer carries the candidate's agreement (see the C2S side): record it
-    // so this copy's tally matches — a free ward then commits on its own accept.
-    offers.recordAccept(site.slug, idOf(activity), rel.candidate);
-    notify(site.slug, { kind: rel.ward === me ? 'offer_received' : 'offer_for_ward', ward: rel.ward, candidate: rel.candidate });
-    return true;
-  }
-
-  // Accept / Reject of an offer we (also) track.
-  const offerId = idOf(activity.object);
-  // §5.6, the answer coming HOME: the ward's server settled a decision we
-  // proposed and answers our Offer. Accept = it settled on what we proposed,
-  // Reject = on the opposite. Only the ward may say so: the answer must come
-  // from the ward the proposal was about, or anyone could close our books.
-  const sent = gated.recallSent(offerId);
-  if (sent && sent.guardian_slug === site.slug) {
-    if (actor !== sent.ward_uri) return false;   // not the ward's voice: not an outcome
-    const outcome = type === 'Accept' ? 'accepted' : 'rejected';
-    gated.settleSent(offerId, outcome);
-    notify(site.slug, { kind: 'gated_outcome', feature: sent.feature, value: !!sent.value, outcome, ward: sent.ward_uri });
-    return true;
-  }
-  // §5.6: a fellow guardian answering a gated-setting proposal. The Accept only
-  // references the offer, so the value comes from the proposal we stored. A
-  // Reject is a vote for the opposite, not a shrug: it is still an answer.
-  const gsOffer = gated.recallGatedOffer(offerId);
-  if (gsOffer && gsOffer.slug === site.slug) {
-    const value = type === 'Accept' ? !!gsOffer.value : !gsOffer.value;
-    const r = gated.recordGatedVote(site.slug, gsOffer.feature, actor, value);
-    if (r.state === 'settled') answerGatedProposer(site, offerId, r);
-    // DOORSLAGGEVEND SCHUIFT MEE (Barts correctie, 8-8). Ik berekende dit een
-    // keer bij het doorsturen en bevroor het. Bij vijf guardians staat er dan
-    // "je beslist niets" -- en zodra er een ja bij komt IS elk van de anderen de
-    // doorslag. Dat is precies de stille kant: het scherm zwijgt op het moment
-    // dat het moet spreken.
-    //
-    // Dus na elke stem die het open laat: de overgeblevenen opnieuw vertellen
-    // waar ze staan. Alleen wie NOG NIET geantwoord heeft, en alleen als het
-    // antwoord verandert -- anders is dit een bericht per stem per guardian.
-    else herzieDoorslag(site, offerId, gsOffer, actor);
-    notify(site.slug, { kind: 'gated_setting', feature: gsOffer.feature, value, state: r.state });
-    return true;
-  }
-  // §3.6.3: a set member answering a running lapse. Irreversible, so even a
-  // full tally leaves it open until the window closes (§3.5); the completion
-  // happens lazily on reads (queues) once the window has run.
-  if (availability.getLapse(offerId)) {
-    const r = availability.lapseVote(offerId, actor, type === 'Accept', Date.now());
-    notify(site.slug, { kind: 'lapse_vote', lapse: offerId, by: actor, state: r && !r.error ? 'recorded' : (r && r.error) || 'refused' });
-    return true;
-  }
-  let offer = offers.getOffer(site.slug, offerId);
-  if (!offer) return false;
-  if (!offers.isParty(offer, actor)) return false;
-
-  if (type === 'Reject') {
-    offers.recordReject(site.slug, offerId, actor);
-    notify(site.slug, { kind: 'offer_rejected', offer: offerId });
-    return true;
-  }
-
-  offers.recordAccept(site.slug, offerId, actor);
-  await maybeCommit(site.slug, offerId);   // commits this copy once the tally is complete (§4.2 may refuse)
-  return true;
-}
-
-/**
- * §4.2 SHOULD: retry the dereference for handshakes left deferred because the
- * candidate could not be read.
- *
- * Waiting for a further activity from a party is not enough: the commit is
- * triggered by the LAST `Accept`, so if that one has already arrived nothing
- * will ever poke it again and the handshake would sit until its window closed.
- * The ward's dashboard polling its own offers queue is this instance's
- * schedule, exactly as a read settles a lapse (§3.6.3).
- *
- * Deliberately not awaited by the read: a poll should render what is true now,
- * not block on someone else's slow server. A retry that succeeds shows up in
- * the next poll, which is the same second or two later.
- */
-export async function retryDeferred(slug) {
-  for (const o of offers.listDeferred(slug)) {
-    await maybeCommit(slug, o.offer_id).catch(() => { /* next poll tries again */ });
-  }
-}
-
-function notify(slug, ev) {
-  try { if (deps && typeof deps.onEvent === 'function') deps.onEvent(slug, ev); } catch { /* best-effort */ }
-}
-
-export default { wireHandshake, handleOutbox, handleInbox, parseRelationship, parseUndoRelationship, endGuardianship, retryDeferred, existingGuardiansOf };
Index: src/services/guardianship/help.js
===================================================================
--- src/services/guardianship/help.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,159 +1,0 @@
-/**
- * Wie er op een hulpvraag af is, en wanneer hij is afgesloten (shaer-lgo).
- *
- * Een hulpvraag (FEP-633c 5.2.1) gaat naar ALLE guardians van een kind, die op
- * verschillende servers zitten. Zonder gedeelde staat denken er twee dat de
- * ander het oppakt -- en dat is precies het scenario waar de reddingsboei voor
- * bestaat.
- *
- * DE FAALSTAND IS HIER NIET VEILIG, en dat maakt dit anders dan elke gate. Bij
- * een gate is "dicht" het veilige antwoord. Hier is de faalstand "iedereen denkt
- * dat het geregeld is", en dat is gevaarlijker dan geen markering. Daaruit volgt
- * de regel die overal in dit bestand terugkomt: bij twijfel is een hulpvraag
- * OPEN.
- *
- * Twee besluiten van Bart (7-8) zitten in de vorm:
- *
- *   OPGEPIKT mag stapelen en vervalt niet, maar VEROUDERT zichtbaar. Twee mensen
- *   die tegelijk reageren op een kind is geen probleem; twee die allebei niets
- *   doen omdat de ander het "geclaimd" had, wel. En een signaal dat vanzelf
- *   verdwijnt laat een hulpvraag er onaangeroerd uitzien terwijl er iemand mee
- *   bezig is.
- *
- *   AFGEHANDELD kent geen terugdraai. Sluiten gebeurt met een stevige
- *   bevestiging, en leeft de vraag daarna nog, dan wordt hij OPNIEUW GESTELD --
- *   een nieuwe hulpvraag. Er wordt niets herschreven, er wordt toegevoegd.
- */
-
-import db from '../../config/database.js';
-
-let _s = null;
-function stmts() {
-  if (!_s) {
-    _s = {
-      ins: db.prepare(`INSERT OR IGNORE INTO ap_help_state (note_uri, guardian_uri, kind, guardian_handle)
-                       VALUES (?,?,?,?)`),
-      forNote: db.prepare('SELECT * FROM ap_help_state WHERE note_uri = ? ORDER BY created_at ASC'),
-      forNotes: db.prepare('SELECT * FROM ap_help_state WHERE note_uri IN (SELECT value FROM json_each(?))'),
-    };
-  }
-  return _s;
-}
-
-/** Leg vast dat iemand deze hulpvraag heeft opgepikt of afgesloten. */
-export function record(noteUri, guardianUri, kind, handle = null) {
-  if (!noteUri || !guardianUri) return;
-  const k = kind === 'handled' ? 'handled' : 'pickup';
-  try { stmts().ins.run(noteUri, guardianUri, k, handle); } catch { /* nooit dragend */ }
-}
-
-/**
- * De staat van een hulpvraag, uit zijn rijen. Puur, zodat de regels te toetsen
- * zijn zonder database of scherm.
- *
- * `oldestPickupAt` is het TIJDSTIP van de oudste oppik, niet de leeftijd. Daar
- * tekent het scherm mee dat een signaal oud wordt -- niets verdwijnt, maar je
- * ziet wel dat er misschien niets meer gebeurt.
- *
- * EEN TIJDSTIP, GEEN LEEFTIJD, en dat is geen smaak. Hier stond `ageMs`, een
- * verschil met `now`, en dus veranderde dit antwoord elke milliseconde. Zodra
- * het paneel een ETag kreeg (9-8) kon die daardoor nooit meer gelijk zijn: de
- * 304 kwam nooit, de lange poll keerde meteen terug, en de browser kwam in een
- * lus van ongeveer een seconde waarin de scrollpositie werd vermalen. Een
- * levende klok in een antwoord maakt dat antwoord onvergelijkbaar met zichzelf.
- *
- * De leeftijd is een weergavedetail en wordt in de client uitgerekend.
- */
-export function helpStatus(rows, now = Date.now()) {
-  const list = rows || [];
-  const pickups = list.filter((r) => r.kind === 'pickup');
-  const done = list.find((r) => r.kind === 'handled') || null;
-  const stamp = (r) => { const t = Date.parse(r.created_at); return isNaN(t) ? null : t; };
-  const oudste = pickups.map(stamp).filter((t) => t !== null).sort((a, b) => a - b)[0];
-  return {
-    // Namen erbij: "door wie" was de hele vraag. Zonder dat is het een vinkje.
-    pickedUpBy: pickups.map((r) => ({ uri: r.guardian_uri, handle: r.guardian_handle || null, at: r.created_at })),
-    handled: done ? { uri: done.guardian_uri, handle: done.guardian_handle || null, at: done.created_at } : null,
-    // Alleen betekenisvol zolang er niets is afgesloten.
-    oldestPickupAt: (!done && oudste) ? new Date(oudste).toISOString() : null,
-    // Waar het scherm op afgaat. Bij twijfel OPEN: een lege lijst, een rij die we
-    // niet kunnen lezen, wat dan ook -- alles wat geen expliciete afsluiting is,
-    // is een hulpvraag die nog op iemand wacht.
-    open: !done,
-  };
-}
-
-/**
- * Een hulpvraag van iemand die je NIET MEER bewaakt.
- *
- * Het loslaat-scherm belooft dit al letterlijk: "je krijgt geen hulpvragen meer
- * van ze". Nieuwe komen inderdaad niet meer binnen, maar wat er al lag bleef in
- * de open lijst staan -- en was niet af te sluiten, want de markeerroute eist
- * dat het nog je ward is en antwoordt anders met 403. De knop stond er dus wel
- * en deed niets.
- *
- * Zo'n vraag is niet AFGEHANDELD -- dat zou een claim zijn over een kind waar je
- * niets meer over te zeggen hebt, en die claim wordt ook nog rondgestuurd. Hij is
- * niet meer van jou. Dat is een derde uitkomst en die hoort als zodanig te lezen.
- *
- * Veilig omdat een guardianship nooit bij de LAATSTE guardian eindigt (3.4,
- * emancipatie): er blijft altijd iemand over voor wie de vraag wel open staat.
- */
-export function withWardship(status, stillWard) {
-  if (stillWard) return status;
-  return { ...status, open: false, formerWard: true };
-}
-
-/** De staat van een hulpvraag zoals die nu is opgeslagen. */
-export function statusOf(noteUri, now = Date.now()) {
-  if (!noteUri) return helpStatus([], now);
-  try { return helpStatus(stmts().forNote.all(noteUri), now); } catch { return helpStatus([], now); }
-}
-
-/** Idem voor een hele lijst in een query, zodat een paneel geen N+1 wordt. */
-export function statusFor(noteUris, now = Date.now()) {
-  const uit = new Map();
-  const lijst = [...new Set((noteUris || []).filter(Boolean))];
-  if (!lijst.length) return uit;
-  let rijen = [];
-  try { rijen = stmts().forNotes.all(JSON.stringify(lijst)); } catch { rijen = []; }
-  const perNote = new Map();
-  for (const r of rijen) {
-    if (!perNote.has(r.note_uri)) perNote.set(r.note_uri, []);
-    perNote.get(r.note_uri).push(r);
-  }
-  for (const uri of lijst) uit.set(uri, helpStatus(perNote.get(uri) || [], now));
-  return uit;
-}
-
-/**
- * De markering als bericht. Bewust een gewone directe note met een
- * shaer:-markering, zoals de zwaai en de afwezigheidsmelding: dan reist het over
- * de bestaande bezorging, en de WARD leest het als wat het is -- er komt iemand.
- */
-export function markerNote({ id, me, noteUri, kind, to }) {
-  const k = kind === 'handled' ? 'handled' : 'pickup';
-  return {
-    id, type: 'Note', attributedTo: me, to,
-    // Ook als antwoord, in gewoon AS2 (26-8): de markering gaat ergens over,
-    // en inReplyTo is hoe je dat zegt zonder dialect. Zie deliverDirectNote,
-    // die dit voor de echte bezorging net zo doet.
-    inReplyTo: noteUri,
-    [k === 'handled' ? 'shaer:helpHandled' : 'shaer:helpPickup']: noteUri,
-    content: k === 'handled'
-      ? '<p>Deze hulpvraag is afgehandeld.</p>'
-      : '<p>Ik kijk hiernaar.</p>',
-  };
-}
-
-/** Leest een binnengekomen note als markering, of null als hij er geen is. */
-export function parseMarker(object) {
-  if (!object || typeof object !== 'object') return null;
-  const pickup = object['shaer:helpPickup'];
-  const handled = object['shaer:helpHandled'];
-  if (typeof handled === 'string' && handled) return { kind: 'handled', noteUri: handled };
-  if (typeof pickup === 'string' && pickup) return { kind: 'pickup', noteUri: pickup };
-  return null;
-}
-
-export default { record, helpStatus, withWardship, statusOf, statusFor, markerNote, parseMarker };
Index: src/services/guardianship/index.js
===================================================================
--- src/services/guardianship/index.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,37 +1,0 @@
-/**
- * Guardianship (FEP-633c "Guardians") — the module.
- *
- * Klonkt's kid-safety feature as one cohesive unit:
- *  - context.js:   the shaer JSON-LD namespace + Relationship vocabulary
- *  - offers.js:    the multi-party handshake state (a port of the Shaer daemon)
- *  - relations.js: the COMMITTED ward ↔ guardian relations + actor props
- *  - handshake.js: the adoption Offer/Accept/Reject over C2S and S2S
- *  - queues.js:    the owner-only dashboard collections (offers/follows/wards)
- *  - notes.js:     the shaer:helpRequest flag on direct notes
- *  - delivery.js:  the direct-note leg a ward's call-for-help rides
- *
- * The shared blocklist (Shaer's "in Orbit") lives NEXT TO this module in
- * BlocklistService. ActivityPubService wires the AP helpers in once and
- * delegates; nothing here imports ActivityPubService back.
- */
-export { SHAER_CONTEXT, GUARDIAN_RELATIONSHIP, GUARDIAN_RELATIONSHIP_COMPACT, isGuardianRelationship } from './context.js';
-export { helpRequestProps, isHelpRequest, waveProps, isWave, awayProps, hasGuardiansProps, objectHasGuardians, externalEmbedsAllowed, externalPlaybackAllowed, wardGateAllowed } from './notes.js';
-export { wireDelivery, c2sVisibility, deliverDirectNote } from './delivery.js';
-export { wireHandshake, handleOutbox as handleGuardianshipOutbox, handleInbox as handleGuardianshipInbox, parseRelationship, parseUndoRelationship, endGuardianship, existingGuardiansOf } from './handshake.js';
-export { offersCollection, followsCollection, outgoingFollowsCollection, logCollection, wardsCollection, guardiansCollection, helpCollection } from './queues.js';
-export * as availability from './availability.js';
-export { wireAvailability } from './availability.js';
-export * as follows from './follows.js';
-export * as outgoing from './outgoing.js';
-export { listForParty as listOffersForParty, getOffer, findOfferAnywhere } from './offers.js';
-export {
-  listGuardians, listWards, isGuardian, getRelation, removeRelation,
-  actorProps as guardianshipActorProps,
-} from './relations.js';
-
-// §5.6 gated settings (decided by the guardians, enforced by the ward's server)
-export * as gated from './gated.js';
-export * as queues from './queues.js';
-// 5.2.1: wie er op een hulpvraag af is en wanneer hij is afgesloten (shaer-lgo)
-export * as help from './help.js';
-export * as gatereq from './gatereq.js';
Index: src/services/guardianship/notes.js
===================================================================
--- src/services/guardianship/notes.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,102 +1,0 @@
-/**
- * Guardianship (FEP-633c) — note properties.
- *
- * The shaer:helpRequest flag (spec 5.2.1): a ward's call for help, only ever
- * on direct notes. Everyone who does not speak shaer can ignore it.
- */
-import { listGuardians } from './relations.js';
-
-/**
- * shaer:hasGuardians (§2.2): an advisory OBJECT hint that the author is a ward,
- * so a remote server can route interactions to the guardians WITHOUT fetching
- * the actor. Stamped on every object a ward publishes; MUST be safely ignorable.
- */
-export function hasGuardiansProps(slug) {
-  try { return (slug && listGuardians(slug).length) ? { 'shaer:hasGuardians': true } : {}; }
-  catch { return {}; }
-}
-
-/**
- * May EXTERNAL (non-fediverse) embeds be shown to this account?
- *
- * A gated feature in the FEP-633c sense: a ward's world outside the fediverse
- * is the guardians' call. `setting` is `sites.external_embeds`:
- *   null/undefined → auto: off for a ward, on for anyone else
- *   0 → off, 1 → on (the guardians decided)
- *
- * Pure, so the rule is testable on its own. The gate is applied SERVER-side:
- * a blocked embed is never serialised into the feed, because an embed that the
- * client merely hides has still been delivered.
- */
-export function externalEmbedsAllowed(setting, isWard) {
-  if (setting === 0 || setting === 1) return setting === 1;
-  return !isWard;
-}
-
-/**
- * May a player run INSIDE the app/page for this account? The heavier sibling
- * of the setting above, and deliberately separate: seeing that a video exists
- * is not the same decision as handing the screen to a third party's player,
- * with its engine, its end-screen and its next-video machine. Same shape, same
- * default (off for a ward), and it only ever matters once embeds are allowed:
- * you cannot play what you may not see.
- */
-export function externalPlaybackAllowed(setting, isWard) {
-  if (setting === 0 || setting === 1) return setting === 1;
-  return !isWard;
-}
-
-/**
- * Dezelfde regel voor de hele gate-familie (8-8, "maak ze allemaal
- * functioneel"): een expliciete 0/1 van de guardians wint, anders de
- * automatiek -- dicht voor een ward, open voor de rest. EEN implementatie,
- * zodat er geen tweede plek is die er anders over kan gaan denken; de twee
- * benoemde varianten hierboven blijven bestaan omdat er tests en aanroepen
- * aan hangen, en doen exact hetzelfde.
- */
-export function wardGateAllowed(setting, isWard) {
-  if (setting === 0 || setting === 1) return setting === 1;
-  return !isWard;
-}
-
-/** True when an incoming object carries the ward hint (§2.2). Register-only for
- *  now; acted on later at reddings-boei / escalation routing. */
-export function objectHasGuardians(o) {
-  return !!o && (o['shaer:hasGuardians'] === true || o.hasGuardians === true);
-}
-
-/** Extra JSON-LD properties for an outgoing note built from an ap_outbox row. */
-export function helpRequestProps(post) {
-  return (post && post.visibility === 'direct' && post.help_request)
-    ? { 'shaer:helpRequest': true }
-    : {};
-}
-
-/** True when an incoming (C2S or S2S) note object carries the flag. */
-export function isHelpRequest(object) {
-  return !!object && (object['shaer:helpRequest'] === true || object.helpRequest === true);
-}
-
-/** shaer:wave: a gentle "thinking of you" from a guardian to its ward. A
- *  private nudge, never a feed post; non-shaer clients see a plain DM. */
-export function waveProps(post) {
-  return (post && post.visibility === 'direct' && post.wave)
-    ? { 'shaer:wave': true }
-    : {};
-}
-
-/** True when an incoming note is a wave. */
-export function isWave(object) {
-  return !!object && (object['shaer:wave'] === true || object.wave === true);
-}
-
-/** shaer:away (3.6.1): a guardian declaring itself away to its ward, with an
- *  end. Rides a direct note like the help request, so a ward on a plain
- *  server reads a human message; endTime is plain AS2. */
-export function awayProps(post) {
-  return (post && post.visibility === 'direct' && post.away_until)
-    ? { 'shaer:away': true, endTime: new Date(post.away_until).toISOString() }
-    : {};
-}
-
-export default { helpRequestProps, isHelpRequest, waveProps, isWave, awayProps, hasGuardiansProps, objectHasGuardians, externalEmbedsAllowed, externalPlaybackAllowed };
Index: src/services/guardianship/offers.js
===================================================================
--- src/services/guardianship/offers.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,163 +1,0 @@
-/**
- * Guardianship (FEP-633c §3) — the multi-party handshake state.
- *
- * A faithful port of the Shaer test daemon's `Handshake`, persisted per local
- * site (so the two implementations behave identically and the clients speak
- * one contract). One row in ap_guardian_offers per offer this instance is a
- * party to; the accepts accumulate in ap_guardian_offer_accepts.
- *
- * The offer commits only when the guardian-candidate returns the handle
- * (§3.1.3) after ward + candidate + at least one existing guardian have
- * accepted (§3.1.2). A single Reject from any party voids it (§3.2). This is
- * the core safety property: no single party creates a guardianship alone, and
- * no new guardian is added without an existing guardian's consent.
- */
-import db from '../../config/database.js';
-
-let _s = null;
-function stmts() {
-  if (!_s) {
-    _s = {
-      insOffer: db.prepare(`INSERT OR IGNORE INTO ap_guardian_offers
-        (offer_id, slug, ward_uri, candidate_uri, existing_guardians, status, ward_handle, candidate_handle, created_at)
-        VALUES (?,?,?,?,?, 'pending', ?, ?, CURRENT_TIMESTAMP)`),
-      getOffer: db.prepare('SELECT * FROM ap_guardian_offers WHERE slug=? AND offer_id=?'),
-      offerAnywhere: db.prepare('SELECT * FROM ap_guardian_offers WHERE offer_id=? LIMIT 1'),
-      setStatus: db.prepare('UPDATE ap_guardian_offers SET status=?, handle=COALESCE(?, handle) WHERE slug=? AND offer_id=?'),
-      listBySlug: db.prepare("SELECT * FROM ap_guardian_offers WHERE slug=? AND status='pending' ORDER BY created_at DESC"),
-      insAccept: db.prepare('INSERT OR IGNORE INTO ap_guardian_offer_accepts (offer_id, slug, party_uri, created_at) VALUES (?,?,?,CURRENT_TIMESTAMP)'),
-      accepts: db.prepare('SELECT party_uri FROM ap_guardian_offer_accepts WHERE slug=? AND offer_id=?'),
-    };
-  }
-  return _s;
-}
-
-const parties = (o) => [o.ward_uri, o.candidate_uri, ...JSON.parse(o.existing_guardians || '[]')];
-const isParty = (o, actor) => !!actor && parties(o).includes(actor);
-const acceptsOf = (o) => stmts().accepts.all(o.slug, o.offer_id).map((r) => r.party_uri);
-
-/** ward + candidate + (no existing guardians OR at least one existing) accepted. */
-export function readyToCommit(o) {
-  if (!o || o.status !== 'pending') return false;
-  const acc = new Set(acceptsOf(o));
-  const existing = JSON.parse(o.existing_guardians || '[]');
-  const existingOk = existing.length === 0 || existing.some((g) => acc.has(g));
-  return acc.has(o.ward_uri) && acc.has(o.candidate_uri) && existingOk;
-}
-
-/** Start tracking an offer on `slug` (idempotent). */
-export function start(slug, { offerId, ward, candidate, existingGuardians = [], wardHandle = null, candidateHandle = null }) {
-  stmts().insOffer.run(offerId, slug, ward, candidate, JSON.stringify(existingGuardians || []), wardHandle, candidateHandle);
-  return stmts().getOffer.get(slug, offerId);
-}
-
-export function getOffer(slug, offerId) { return stmts().getOffer.get(slug, offerId); }
-export function findOfferAnywhere(offerId) { return stmts().offerAnywhere.get(offerId); }
-
-/** Record an Accept from one party; ignored if not a party or already resolved. */
-export function recordAccept(slug, offerId, party) {
-  const o = stmts().getOffer.get(slug, offerId);
-  if (!o || o.status !== 'pending' || !isParty(o, party)) return o;
-  stmts().insAccept.run(offerId, slug, party);
-  return stmts().getOffer.get(slug, offerId);
-}
-
-/** A single Reject from any party voids the handshake (§3.2). */
-export function recordReject(slug, offerId, party) {
-  const o = stmts().getOffer.get(slug, offerId);
-  if (!o || o.status !== 'pending' || !isParty(o, party)) return o;
-  stmts().setStatus.run('void', null, slug, offerId);
-  return stmts().getOffer.get(slug, offerId);
-}
-
-/** Commit (only when ready): store the returned handle, mark committed. */
-export function commit(slug, offerId, handle) {
-  const o = stmts().getOffer.get(slug, offerId);
-  if (!o || o.status !== 'pending' || !readyToCommit(o)) return null;
-  stmts().setStatus.run('committed', handle || null, slug, offerId);
-  return stmts().getOffer.get(slug, offerId);
-}
-
-/**
- * How long a guardianship handshake stays open (§3.5). Adding a guardian is a
- * reversible decision, but not a quick one: the ward, the candidate and every
- * existing guardian have to answer, and they are people, sometimes on holiday.
- * A week is long enough that nobody is rushed and short enough that a forgotten
- * offer does not sit in a child's queue for a month looking like a live choice.
- */
-export const OFFER_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
-
-/** SQLite writes CURRENT_TIMESTAMP as UTC 'YYYY-MM-DD HH:MM:SS', which
- *  Date.parse reads as LOCAL time — hours out, and enough to expire an offer
- *  early or late. Same correction as ActivityPubService.isoStamp. */
-const stampMs = (v) => {
-  const s = String(v || '');
-  return Date.parse(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}$/.test(s) ? `${s.replace(' ', 'T')}Z` : s);
-};
-
-export const closesAt = (o) => stampMs(o.created_at) + OFFER_WINDOW_MS;
-
-/**
- * §3.5 fails closed: once the window has run, a handshake that never completed
- * is over. WHICH failure it was matters (§4.2), so the two get different
- * terminal states and neither of them is `void`:
- *
- *   'expired'     — the parties never all answered. Nothing to say about anyone.
- *   'unverified'  — everyone answered; the candidate could never be read, so
- *                   the check never got to run. The parties MUST be told this
- *                   and MUST NOT be told the candidate was refused. It was not:
- *                   nobody ever managed to look.
- */
-export function expireIfDue(slug, offerId, now = Date.now()) {
-  const o = stmts().getOffer.get(slug, offerId);
-  if (!o || o.status !== 'pending') return null;
-  const due = closesAt(o);
-  if (!Number.isFinite(due) || due > now) return null;
-  const status = readyToCommit(o) ? 'unverified' : 'expired';
-  stmts().setStatus.run(status, null, slug, offerId);
-  return { ...o, status };
-}
-
-/** Pending offers where `me` is a party — the offers queue (daemon shape).
- *  Reads are where lazy completion happens, as with the lapses (§3.6.3): a
- *  closed window is settled here rather than by a sweeper nobody runs. */
-export function listForParty(slug, me, now = Date.now()) {
-  if (!stmts().listBySlug.get) return [];
-  for (const o of stmts().listBySlug.all(slug)) expireIfDue(slug, o.offer_id, now);
-  return stmts().listBySlug.all(slug).filter((o) => isParty(o, me));
-}
-
-/** Handshakes whose tally is complete but which are not committed: the §4.2
- *  deferred set, waiting on a candidate nobody could dereference. */
-export function listDeferred(slug) {
-  if (!stmts().listBySlug.get) return [];
-  return stmts().listBySlug.all(slug).filter((o) => readyToCommit(o));
-}
-
-/** One offer as the offers-queue item the Shaer clients parse. */
-export function queueItem(o, me) {
-  const acc = acceptsOf(o).sort();
-  return {
-    id: o.offer_id,
-    type: 'Offer',
-    actor: o.candidate_uri,
-    object: { type: 'Relationship', subject: o.ward_uri, relationship: 'shaer:Guardian', object: o.candidate_uri },
-    'shaer:ward': o.ward_uri,
-    'shaer:candidate': o.candidate_uri,
-    'shaer:existingGuardians': JSON.parse(o.existing_guardians || '[]'),
-    'shaer:acceptedBy': acc,
-    'shaer:needsMyAccept': !acc.includes(me),
-    'shaer:readyToCommit': readyToCommit(o),
-    'shaer:iAmCandidate': me === o.candidate_uri,
-    'shaer:wardHandle': o.ward_handle || undefined,
-    'shaer:candidateHandle': o.candidate_handle || undefined,
-    published: o.created_at,
-  };
-}
-
-export { parties, isParty, acceptsOf };
-export default {
-  start, getOffer, findOfferAnywhere, recordAccept, recordReject, commit,
-  readyToCommit, listForParty, queueItem, parties, isParty, acceptsOf,
-  OFFER_WINDOW_MS, closesAt, expireIfDue, listDeferred,
-};
Index: src/services/guardianship/outgoing.js
===================================================================
--- src/services/guardianship/outgoing.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,126 +1,0 @@
-/**
- * Guardianship (FEP-633c §5.3, the other direction) — gating a ward's OWN
- * follows. Bead shaer-p729; the design is in docs/ward-outbound-follows-design.md,
- * and the spec question it answers is shaer-yeo5.
- *
- * The inbound gate in `follows.js` decides who may follow a ward. This one
- * decides who a ward may follow. Until now that went out unchecked: the
- * guardians got a note afterwards (1a2f206), which is informing, not gating —
- * the door is already open by the time the message arrives.
- *
- * The rule (Barts besluit): every outgoing follow waits for a guardian, EXCEPT
- * where the target already follows the ward through the gate. A guardian
- * already said yes to that person; asking the same question twice only teaches
- * people to stop reading the question.
- */
-import db from '../../config/database.js';
-import { followThreshold } from './follows.js';
-
-let _s = null;
-function stmts() {
-  if (!_s) {
-    _s = {
-      ins: db.prepare(`INSERT OR IGNORE INTO ap_pending_outgoing_follows
-        (id, ward_slug, target_uri, target_inbox, target_name, target_handle, target_icon, quorum, created_at)
-        VALUES (?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`),
-      get: db.prepare('SELECT * FROM ap_pending_outgoing_follows WHERE id = ?'),
-      byTarget: db.prepare('SELECT * FROM ap_pending_outgoing_follows WHERE ward_slug = ? AND target_uri = ?'),
-      byWard: db.prepare("SELECT * FROM ap_pending_outgoing_follows WHERE ward_slug = ? AND status = 'pending' ORDER BY created_at DESC"),
-      approve: db.prepare('INSERT OR IGNORE INTO ap_outgoing_follow_approvals (follow_id, guardian_uri, decision, created_at) VALUES (?,?,?,CURRENT_TIMESTAMP)'),
-      answers: db.prepare('SELECT guardian_uri, decision FROM ap_outgoing_follow_approvals WHERE follow_id = ?'),
-      setStatus: db.prepare('UPDATE ap_pending_outgoing_follows SET status = ? WHERE id = ?'),
-      del: db.prepare('DELETE FROM ap_pending_outgoing_follows WHERE id = ?'),
-      delByTarget: db.prepare('DELETE FROM ap_pending_outgoing_follows WHERE ward_slug = ? AND target_uri = ?'),
-      gateApproved: db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND actor_uri = ? AND gate_approved = 1'),
-    };
-  }
-  return _s;
-}
-
-/**
- * Does this target already follow the ward, with a guardian's blessing?
- *
- * Only a gate-approved follower counts. A follower a free actor picked up
- * before it was ever a ward was never seen by a guardian, so following them
- * back is a new question, not a settled one. (Rows that predate the marker are
- * grandfathered at migration; see config/database.js.)
- */
-export function isMutual(wardSlug, targetUri) {
-  return !!stmts().gateApproved.get(wardSlug, targetUri);
-}
-
-/** Record an outgoing follow awaiting guardian approval. */
-export function recordPending(wardSlug, f) {
-  stmts().ins.run(
-    f.id, wardSlug, f.target, f.inbox || null,
-    f.name || null, f.handle || null, f.icon || null, f.quorum || 'any',
-  );
-  return stmts().byTarget.get(wardSlug, f.target);
-}
-
-export function getPending(id) { return stmts().get.get(id); }
-export function findFor(wardSlug, targetUri) { return stmts().byTarget.get(wardSlug, targetUri); }
-
-/** Outgoing follows this ward is waiting on — the guardian's queue. */
-export function listForWard(wardSlug) { return stmts().byWard.all(wardSlug); }
-
-/**
- * A guardian's answer. Same shape and the same quorum arithmetic as the
- * inbound gate, so the two directions cannot drift apart in how they count:
- * a single reject denies outright, approvals accumulate toward the quorum.
- */
-export function decide(id, guardianUri, decision, guardiansOfWard) {
-  const follow = stmts().get.get(id);
-  if (!follow || follow.status !== 'pending') return { outcome: 'gone', follow };
-  stmts().approve.run(id, guardianUri, decision === 'reject' ? 'reject' : 'approve');
-  const rows = stmts().answers.all(id);
-  if (rows.some((r) => r.decision === 'reject')) {
-    stmts().setStatus.run('denied', id);
-    return { outcome: 'rejected', follow };
-  }
-  const approvers = new Set(rows.filter((r) => r.decision === 'approve').map((r) => r.guardian_uri));
-  const guardians = (guardiansOfWard || []).filter(Boolean);
-  // Dezelfde eenvoudige meerderheid als bij een inkomend volgverzoek
-  // (followThreshold): het is dezelfde vraag, alleen omgedraaid. Twee
-  // verschillende drempels voor "mag dit kind met deze persoon te maken hebben"
-  // zou een guardian nooit kunnen uitleggen.
-  const enough = approvers.size >= followThreshold(guardians.length);
-  if (enough) {
-    stmts().setStatus.run('approved', id);
-    return { outcome: 'approved', follow };
-  }
-  return { outcome: 'waiting', follow };
-}
-
-/** The ward changed its mind, or blocked the target: the request is gone. */
-export function withdraw(wardSlug, targetUri) { stmts().delByTarget.run(wardSlug, targetUri); }
-export function remove(id) { stmts().del.run(id); }
-
-/** One request as the queue item the Shaer clients parse, mirroring the
- *  inbound gated-follow item so a dashboard can render both side by side. */
-export function queueItem(o, me) {
-  const rows = stmts().answers.all(o.id);
-  return {
-    id: o.id,
-    type: 'Follow',
-    // De ward is de ACTOR van zijn eigen Follow, en dat hoort een actor-URI te
-    // zijn: hier stond de slug ('mee'), en elke lezer vergelijkt dit veld met
-    // actor-URI's. Een ward zag zijn eigen verzoeken daardoor nooit als de
-    // zijne -- ze vielen in de bak "hoort niet bij een ward die je hebt", met
-    // knoppen erbij die hij niet mag gebruiken. `listForWard(slug)` levert per
-    // definitie de verzoeken van de LEZER, dus dat is `me`.
-    actor: me,
-    object: o.target_uri,
-    'shaer:direction': 'outgoing',
-    'shaer:target': o.target_uri,
-    'shaer:targetHandle': o.target_handle || undefined,
-    'shaer:quorum': o.quorum || 'any',
-    'shaer:approvals': rows.filter((r) => r.decision === 'approve').length,
-    'shaer:myVote': rows.some((r) => r.guardian_uri === me),
-    published: o.created_at,
-  };
-}
-
-export default {
-  isMutual, recordPending, getPending, findFor, listForWard, decide, withdraw, remove, queueItem,
-};
Index: src/services/guardianship/queues.js
===================================================================
--- src/services/guardianship/queues.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,328 +1,0 @@
-/**
- * Guardianship (FEP-633c) — the owner-only dashboard queues.
- *
- * Three OrderedCollections on the actor (shaer:queues), same contract as the
- * Shaer test daemon so the iOS/Android dashboards read them as-is:
- *  - offers:  pending handshake offers where I am a party (§3), with the full
- *             accept tally so the client shows the right action
- *  - follows: pending gated follows ON my wards (§5.3), Fase 2 (shaer-jdb)
- *  - wards:   my committed wards
- */
-import * as offers from './offers.js';
-import { pagedCollection } from '../ap-core.js';
-import * as relations from './relations.js';
-import * as availability from './availability.js';
-import * as outgoing from './outgoing.js';
-import * as follows from './follows.js';
-import * as gated from './gated.js';
-import * as gatereq from './gatereq.js';
-import * as help from './help.js';
-import db from '../../config/database.js';
-import * as handshake from './handshake.js';
-
-// De @context zet de route erop (queueRoute), dus hier bewust niet
-// pagedCollection uit ap-core -- die voegt hem toe en dan staat hij er twee
-// keer. Wel dezelfde paginavelden, om dezelfde reden: een lezer die de
-// paginaweg volgt hoort niet dood te lopen (Funkwhale, 11-8).
-// Dezelfde bouwer als de rest (shaer-sk4). Hier stond een eigen kopie die
-// `first` en `last` allebei op ?page=1 zette en nooit sneed -- de vorm die
-// Robin op 13-8 aanwees, in het tweede exemplaar. Een tweede spelling van
-// dezelfde zaak loopt vanzelf uit elkaar; nu is er een.
-const collection = (id, items) => {
-  // ZONDER @context: de route zet hem erop, en twee keer maakt het document
-  // ongeldig. Vastgelegd in ap-outbox-paging.test.js, en die test ving dit ook
-  // meteen toen ik hem hier vergat.
-  const { '@context': _weg, ...rest } = pagedCollection(id, items);
-  return rest;
-};
-
-/** Pending offers where the local site is a party, each with its accept
- *  tally. The same collection carries the running lapses (§3.6.3) this
- *  account is a party to, exactly as the daemon serves them, so the Shaer
- *  clients render both without a second fetch. */
-export function offersCollection(id, slug, me) {
-  // §4.2: a handshake whose candidate could not be dereferenced is deferred,
-  // not decided, and the last Accept may already have landed — so nothing else
-  // would ever retry it. This poll is the schedule. Not awaited: the read
-  // answers with what is true now, and a retry that succeeds surfaces in the
-  // next one. `listForParty` settles closed windows on the way past.
-  handshake.retryDeferred(slug).catch(() => { /* the next read tries again */ });
-  const items = offers.listForParty(slug, me).map((o) => offers.queueItem(o, me));
-  items.push(...availability.lapseQueueItems(slug, me, Date.now()));
-  return collection(id, items);
-}
-
-/**
- * Gate-verzoeken OP mijn wards die op mijn antwoord wachten (Guardianship Fase 2,
- * shaer-jdb). Dit was een lege stub: de gating zelf werkt sinds shaer-hxg, maar
- * werd nooit aan een C2S-client doorgegeven omdat de koers toen op de PWA lag.
- *
- * Twee bronnen, want een guardian kan wards op andere servers hebben en (nog)
- * op deze:
- *   - ap_follow_reviews: de doorgestuurde kopie van een REMOTE ward
- *   - ap_pending_follows: een ward op deze instance
- * Zie shaer-h6u: die tweede hoort op termijn ook over de lijn te gaan.
- */
-export function followsCollection(id, slug, me) {
-  const items = follows.listReviewsByDirection(slug, 'incoming')
-    .map((r) => follows.reviewQueueItem(r, me));
-  for (const w of relations.listWards(slug)) {
-    const wardSlug = slugOf(w.other_uri);
-    if (!wardSlug) continue;
-    for (const p of follows.listForWard(wardSlug)) {
-      items.push({
-        id: p.id, type: 'Follow', actor: p.follower_uri, object: w.other_uri,
-        'shaer:direction': 'incoming', 'shaer:ward': w.other_uri,
-        'shaer:follower': p.follower_uri, 'shaer:followerHandle': p.follower_handle || undefined,
-        'shaer:quorum': p.quorum || 'any', published: p.created_at,
-      });
-    }
-  }
-  return collection(id, items);
-}
-
-/** De slug van een actor-uri op DEZE instance, of null als hij elders woont. */
-function slugOf(uri) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !String(uri || '').startsWith(`${base}/ap/users/`)) return null;
-  return decodeURIComponent(String(uri).slice(`${base}/ap/users/`.length).split(/[/?#]/)[0]) || null;
-}
-
-/**
- * §5.3 uitgaand. Twee lezers, een wachtrij, en dat kan omdat §1 een ward en een
- * guardian wederzijds uitsluit: je bent het een of het ander.
- *
- *   ALS WARD      wat IK wil volgen en waar mijn guardians nog over moeten
- *   ALS GUARDIAN  wat mijn WARDS willen volgen en waar IK over moet (shaer-jdb)
- *
- * Dat tweede ontbrak. De wachtrij serveerde alleen listForWard(slug), en voor
- * een guardian is dat per definitie leeg -- dus het scherm "Your wards want to
- * follow" kon nooit iets tonen.
- */
-export function outgoingFollowsCollection(id, slug, me) {
-  const items = outgoing.listForWard(slug).map((o) => outgoing.queueItem(o, me));
-  for (const r of follows.listReviewsByDirection(slug, 'outgoing')) {
-    items.push(follows.reviewQueueItem(r, me));
-  }
-  return collection(id, items);
-}
-
-/** The guardian's committed wards, with cached handle for display. */
-export function wardsCollection(id, slug) {
-  const items = relations.listWards(slug)
-    .map((r) => ({
-      id: r.other_uri,
-      'shaer:handle': r.other_handle || undefined,
-      since: r.created_at,
-      // Alles wat voor dit kind gated is, met soort, drempel en lopend voorstel
-      // (shaer-ahy.1). Zonder dit kon een app wel een ward TONEN maar niets over
-      // hem zeggen -- en dat is precies de helft van het antwoord op "wat mag
-      // dit kind". Dezelfde rijen als het PWA-paneel, uit dezelfde functie.
-      'shaer:gates': wardGates(slug, r.other_uri),
-    }));
-  return collection(id, items);
-}
-
-/** The ward's guardians with their availability (§3.6.1: never public,
- *  owner-only): the real size of the safety net. Same shape as the daemon. */
-export function guardiansCollection(id, slug) {
-  const uris = relations.listGuardians(slug).map((r) => r.other_uri);
-  return collection(id, availability.statusesFor(slug, uris, Date.now()));
-}
-
-/**
- * Het logboek (§4.2): wat er is besloten, en waarom.
- *
- * Geen wachtrij, en daarom een eigen sleutel op de actor. Elk item draagt zijn
- * soort en, als die er was, de REDEN -- want zonder die reden merkte een ward
- * een weigering alleen doordat er iets uit een lijst verdween.
- *
- * `type` is geen AS2-werkwoord: de meeste soorten zijn er geen. Een lapse-stem
- * of een opgepakte hulpvraag is geen Accept, en het zo noemen zou netter lezen
- * dan het is.
- *
- * De lezer levert `listEvents` aan; deze module kent ActivityPubService niet en
- * houdt dat zo (zie de kop van delivery.js).
- */
-export function logCollection(id, slug, listEvents) {
-  const items = (typeof listEvents === 'function' ? listEvents(slug) : []).map((e) => {
-    const { id: n, kind, created, ...rest } = e;
-    const item = { id: `${id}/${n}`, type: 'shaer:Event', 'shaer:kind': kind, published: created };
-    for (const [k, v] of Object.entries(rest)) {
-      if (v === undefined || v === null) continue;
-      item[k.startsWith('shaer:') ? k : `shaer:${k}`] = v;
-    }
-    return item;
-  });
-  return collection(id, items);
-}
-
-export default { offersCollection, followsCollection, outgoingFollowsCollection, wardsCollection, guardiansCollection, helpCollection, helpItemsFor, wardGates, wardGuardianStatuses, logCollection };
-
-// ── Wat er voor een ward gated is (shaer-ahy.1) ─────────────────────────
-//
-// STOND IN routes/guardian.js, en daar kon alleen de PWA erbij. De Shaer-apps
-// lezen dezelfde toestand via de wards-queue, en een tweede berekening naast
-// deze zou vroeg of laat een ander antwoord geven op dezelfde vraag -- dat is
-// hier geen schoonheidsfoutje maar twee guardians die een verschillend beeld
-// van hetzelfde kind krijgen. Een plek dus, en beide schermen lezen eruit.
-/** The guardians of a ward WE host, with availability (3.6.1: owner-only in
- *  spirit; the co-guardians are among the owners of the relationship). Null
- *  for a remote ward: its server tracks availability, not us. */
-export function wardGuardianStatuses(wardUri) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !String(wardUri || '').startsWith(`${base}/`)) return null;
-  const slug = String(wardUri).trim().replace(/\/+$/, '').split('/').pop();
-  try {
-    const uris = relations.listGuardians(slug).map((g) => ({ uri: g.other_uri, handle: g.other_handle }));
-    const st = Object.fromEntries(
-      availability.statusesFor(slug, uris.map((u) => u.uri), Date.now()).map((s) => [s.id, s]),
-    );
-    return uris.map((u) => ({
-      uri: u.uri,
-      handle: u.handle,
-      availability: (st[u.uri] || {})['shaer:availability'] || 'active',
-      awayUntil: (st[u.uri] || {})['shaer:awayUntil'] || null,
-      lapse: (st[u.uri] || {})['shaer:lapse'] || null,
-    }));
-  } catch { return null; }
-}
-/**
- * De gate-rijen van een ward voor het paneel.
- *
- * De standen komen uit onze eigen kolommen als we het kind hosten; bij een ward
- * elders weten we ze niet en blijft het NULL -- onbekend, niet uit. Het aantal
- * guardians idem: dat wordt op de server van die ward bijgehouden, en zonder dat
- * getal wordt er geen drempel verzonnen.
- */
-export function wardGates(mySlug, wardUri) {
-  const statuses = wardGuardianStatuses(wardUri);
-  // Per richting geteld, want het zijn twee zorgen. "follows: 3 wachtend" liet
-  // een guardian niet zien of er drie vreemden bij zijn kind willen of dat zijn
-  // kind drie keer heeft gevraagd of het iemand mag volgen (shaer-p729).
-  const wachtendIn = follows.listReviewsByDirection(mySlug, 'incoming')
-    .filter((r) => r.ward_uri === wardUri).length;
-  const wachtendUit = follows.listReviewsByDirection(mySlug, 'outgoing')
-    .filter((r) => r.ward_uri === wardUri).length;
-  return gated.gateRows({
-    // Uit de BESLUITEN, niet uit onze eigen kolom. Er zijn geen lokale accounts:
-    // elke ward woont elders, dus wardEmbedSetting() gaf voor iedere ward null en
-    // stond er in het paneel overal "onbekend". Wat een guardian wel heeft is de
-    // uitslag van wat hij voorstelde.
-    settings: Object.fromEntries(gated.GATE_CATALOGUE
-      .filter((g) => g.available !== false && gated.featureColumn(g.feature))
-      .map((g) => [g.feature, gated.knownSetting(mySlug, wardUri, g.feature)])),
-    guardianCount: statuses ? statuses.length : null,
-    proposals: gated.listSent(mySlug, wardUri).map((p) => ({
-      feature: p.feature, value: !!p.value, status: gated.sentStatus(p, Date.now()),
-    })),
-    waiting: {
-      'shaer:follows': wachtendIn || undefined,
-      'shaer:following': wachtendUit || undefined,
-    },
-    // De vraag van het kind zelf staat APART van wat er in een wachtrij staat
-    // (shaer-8ru). Allebei "n waiting" noemen maakt van twee verschillende
-    // dingen een getal: drie onbekenden die je kind willen volgen is iets heel
-    // anders dan je kind dat een keer vraagt of muziek aan mag. Wel bij de poort
-    // waar het over gaat, want een aparte lijst vergeet je.
-    requested: gatereq.waitingFor(mySlug, wardUri),
-  });
-}
-
-
-// ── Hulpvragen met hun staat (shaer-lgo, shaer-ahy.1) ───────────────────
-//
-// De PWA had dit al; de apps kregen alleen de losse notes uit de feed en wisten
-// dus NIET of er al iemand op af was. Daarom bleef een afgehandeld verzoek daar
-// gewoon staan -- Barts melding. De staat wordt hier een keer berekend, zoals bij
-// wardGates: twee berekeningen zouden twee guardians een ander beeld geven van
-// hetzelfde kind.
-
-/**
- * De hulpvragen van deze guardian, met wie erop af is en of het dicht is.
- *
- * OPEN VRAGEN WORDEN NOOIT AFGEKAPT, en dat is geen ruimhartigheid maar de reden
- * dat de app iets mag CONCLUDEREN uit afwezigheid (Barts punt, 8-8).
- *
- * Dit stond op 50, en ik noemde 'tientallen hulpvragen bij een guardian' een
- * randgeval. Bart wees op de jeugdzorgmedewerker: die heeft geen handvol wards
- * maar een caseload, en voor hem is dat een gewone dinsdag. De gebruiker die dit
- * het hardst nodig heeft was precies degene voor wie het brak.
- *
- * Met een afkap op alles zag een app een oudere vraag niet in de queue, vond geen
- * staat, en toonde hem -- terecht, want bij twijfel OPEN -- als openstaand. Een
- * allang afgehandelde hulpvraag die weer om aandacht vraagt. Nu geldt: staat hij
- * niet in de queue, dan is hij NIET open. Die gevolgtrekking klopt alleen zolang
- * we open vragen volledig leveren.
- *
- * De geschiedenis mag wel afgekapt: die vraagt niets, en wat eraf valt is nog
- * steeds op de server te vinden.
- */
-export function helpItemsFor(slug, historyLimit = 50) {
-  let rijen = [];
-  try {
-    // Alle velden die een kaart kan tonen, niet alleen die van de queue: de
-    // PWA had hierom een EIGEN kopie van deze query -- mét een afkap op 50,
-    // waardoor de fix hierboven aan het paneel voorbijging (Barts 429-jacht,
-    // 9-8). Een tweede weg naar dezelfde staat is precies wat er bij de
-    // reply-gate al misging; nu is dit de enige weg, en dan hoort hij ook te
-    // dragen wat een kaart nodig heeft. De extra kolommen kosten de queue
-    // niets: die leest ze gewoon niet.
-    rijen = db.prepare(
-      `SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, content, published, created_at,
-              emoji_json, actor_emoji_json, media_json, quote_json, embed_json
-       FROM ap_mentions WHERE slug = ? AND help_request = 1 ORDER BY created_at DESC`,
-    ).all(slug);
-  } catch { return []; }
-  const staat = help.statusFor(rijen.map((r) => r.object_uri));
-  const mijn = new Set(relations.listWards(slug).map((w) => w.other_uri));
-  const alles = rijen.map((r) => ({
-    ...r,
-    // Bij twijfel OPEN. Een hulpvraag die er afgehandeld uitziet terwijl hij dat
-    // niet is, is de gevaarlijke fout -- niet andersom.
-    state: help.withWardship(
-      staat.get(r.object_uri) || { open: true, pickedUpBy: [], handled: null, oldestPickupAt: null },
-      mijn.has(r.actor_uri),
-    ),
-  }));
-  const open = alles.filter((h) => h.state.open);
-  const rest = alles.filter((h) => !h.state.open).slice(0, historyLimit);
-  return [...open, ...rest];
-}
-
-/** Dezelfde vragen als collectie voor de apps (5.2.1). */
-export function helpCollection(id, slug) {
-  const items = helpItemsFor(slug).map((h) => ({
-    id: h.object_uri,
-    type: 'Note',
-    attributedTo: h.actor_uri,
-    'shaer:handle': h.actor_handle || undefined,
-    content: h.content || '',
-    published: h.published || h.created_at,
-    'shaer:helpRequest': true,
-    // De staat als platte velden: een app hoeft hem niet af te leiden, en kan
-    // hem dus ook niet anders afleiden dan het paneel.
-    'shaer:open': h.state.open,
-    'shaer:handledBy': h.state.handled ? (h.state.handled.handle || h.state.handled.uri) : undefined,
-    'shaer:handledAt': h.state.handled ? h.state.handled.at : undefined,
-    'shaer:pickedUpBy': h.state.pickedUpBy.map((p) => p.handle || p.uri),
-    // HOE OUD het oudste oppakken is (shaer-lgo). Barts besluit was dat
-    // "opgepikt" niet vervalt maar zichtbaar VEROUDERT -- het verschil tussen
-    // "er is iemand mee bezig" en "er was ooit iemand mee bezig". Het paneel
-    // toonde dat al; de apps konden het niet, want dit veld bleef hier liggen.
-    // Een oppak van vijf minuten zag er daar uit als een van vijf dagen, en de
-    // faalstand is hier nou juist "iedereen denkt dat het geregeld is".
-    //
-    // Als TIJDSTIP en niet als leeftijd: een leeftijd maakt elk antwoord anders
-    // en dan kan de ETag nooit gelijk zijn. De client rekent zelf terug, precies
-    // zoals guardian.js het doet.
-    'shaer:oldestPickupAt': h.state.oldestPickupAt || undefined,
-    'shaer:formerWard': h.state.formerWard || undefined,
-  }));
-  const coll = collection(id, items);
-  // Het teken dat de app mag concluderen uit afwezigheid: elke OPEN vraag zit
-  // hierin. Ontbreekt deze vlag (een oudere server), dan valt de app terug op
-  // bij-twijfel-open, en dat is de veilige kant.
-  coll['shaer:openComplete'] = true;
-  return coll;
-}
Index: src/services/guardianship/relations.js
===================================================================
--- src/services/guardianship/relations.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,103 +1,0 @@
-/**
- * Guardianship (FEP-633c) — the COMMITTED ward ↔ guardian relations
- * (ap_guardianships). Pending offers live in offers.js; a row here means the
- * handshake committed (§3.1.4). Every row is one relation seen from a LOCAL
- * site: role 'guardian' = the site guards other_uri; role 'ward' = other_uri
- * guards the site.
- */
-import db from '../../config/database.js';
-
-let _s = null;
-function stmts() {
-  if (!_s) {
-    _s = {
-      commit: db.prepare(`INSERT INTO ap_guardianships (slug, role, other_uri, other_handle, status, offer_id, created_at)
-                          VALUES (?,?,?,?, 'accepted', ?, CURRENT_TIMESTAMP)
-                          ON CONFLICT(slug, role, other_uri) DO UPDATE SET status='accepted', offer_id=excluded.offer_id`),
-      del: db.prepare('DELETE FROM ap_guardianships WHERE slug=? AND role=? AND other_uri=?'),
-      bySlugRole: db.prepare("SELECT * FROM ap_guardianships WHERE slug=? AND role=? AND status='accepted' ORDER BY created_at DESC"),
-      one: db.prepare('SELECT * FROM ap_guardianships WHERE slug=? AND role=? AND other_uri=?'),
-    };
-  }
-  return _s;
-}
-
-// ── Reads ────────────────────────────────────────────────────────────────
-
-/** Accepted guardian URIs of a local ward (feeds shaer:guardians). */
-export function listGuardians(slug) { return stmts().bySlugRole.all(slug, 'ward'); }
-
-/** Accepted wards of a local guardian (the wards queue). */
-export function listWards(slug) { return stmts().bySlugRole.all(slug, 'guardian'); }
-
-/** A site is a guardian once it stands in any accepted guardian relation. */
-export function isGuardian(slug) { return listWards(slug).length > 0; }
-
-export function getRelation(slug, role, otherUri) { return stmts().one.get(slug, role, otherUri); }
-
-// ── Writes (only the handshake commit lands here) ────────────────────────
-
-/** The local ward gains a guardian (commit, §3.1.4). */
-export function commitGuardianForWard(wardSlug, guardianUri, { handle = null, offerId = null } = {}) {
-  stmts().commit.run(wardSlug, 'ward', guardianUri, handle, offerId);
-  return stmts().one.get(wardSlug, 'ward', guardianUri);
-}
-
-/** The local guardian gains a ward (commit, §3.1.4). */
-export function commitWardForGuardian(guardianSlug, wardUri, { handle = null, offerId = null } = {}) {
-  stmts().commit.run(guardianSlug, 'guardian', wardUri, handle, offerId);
-  return stmts().one.get(guardianSlug, 'guardian', wardUri);
-}
-
-/** End a relation locally (Undo, §3.2 — federation of the Undo is Fase 4). */
-export function removeRelation(slug, role, otherUri) {
-  stmts().del.run(slug, role, otherUri);
-  return { ok: true };
-}
-
-// ── Actor document (FEP-633c §2) ─────────────────────────────────────────
-
-/**
- * Guardianship props for a local actor doc. `id` is the actor URI.
- * - shaer:guardians: accepted guardians of this ward (omitted when none, §2.1)
- * - shaer:isGuardian: true once the site guards anyone
- * - shaer:queues: the owner-only dashboard collections
- *
- * §1 mutual exclusion: a ward (has guardians) is never a guardian, so
- * shaer:isGuardian is suppressed if guardians exist; the offer path already
- * bars a ward from offering.
- */
-export function actorProps(id, slug) {
-  const props = {
-    'shaer:queues': {
-      offers: `${id}/queues/offers`,
-      follows: `${id}/queues/follows`,
-      // Both directions of §5.3, kept apart on purpose: a guardian must be able
-      // to tell "someone wants to follow your ward" from "your ward wants to
-      // follow someone". Same mechanism, opposite question, different words in
-      // the interface (shaer-p729).
-      outgoingFollows: `${id}/queues/outgoing-follows`,
-      wards: `${id}/queues/wards`,
-      guardians: `${id}/queues/guardians`,
-      help: `${id}/queues/help`,
-    },
-    // NAAST de wachtrijen, niet erin. Alles onder shaer:queues wacht op een
-    // antwoord; dit is wat er al besloten is, met de reden erbij (§4.2).
-    // Geschiedenis onderbrengen bij een woord dat "wachtend" betekent maakt van
-    // twee dingen één, en dat is precies de fout die de rest van deze module
-    // net heeft opgeruimd.
-    'shaer:log': `${id}/log`,
-  };
-  const guardians = listGuardians(slug).map((r) => r.other_uri);
-  if (guardians.length) {
-    props['shaer:guardians'] = guardians;   // a ward
-  } else if (isGuardian(slug)) {
-    props['shaer:isGuardian'] = true;        // a guardian (never both, §1)
-  }
-  return props;
-}
-
-export default {
-  listGuardians, listWards, isGuardian, getRelation,
-  commitGuardianForWard, commitWardForGuardian, removeRelation, actorProps,
-};
Index: src/services/i18n.js
===================================================================
--- src/services/i18n.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,3073 +1,0 @@
-// i18n — eenvoudige interface-vertaling (UI-strings), bezoeker-instelbaar.
-//
-// Taalkeuze: req.session.lang (gezet via /lang/:code) → anders browser-taal
-// (Accept-Language) → anders 'nl'. De helper t(lang, key, vars) zoekt de string op
-// in DICT[lang], valt terug op 'nl', dan op de key zelf. Alleen INTERFACE-teksten;
-// door gebruikers geschreven content (posts, sitenaam, bio) wordt niet vertaald.
-
-export const SUPPORTED = ['nl', 'en', 'de'];
-export const LANG_NAMES = { nl: 'Nederlands', en: 'English', de: 'Deutsch' };
-
-const DICT = {
-  nl: {
-    'nav.back_to_site': '← Terug naar site',
-    'nav.fediverse': 'Fediverse',
-    'nav.home': 'Home',
-    'nav.archive': 'Archief',
-    'nav.search': 'Zoeken',
-    'nav.theme': 'Thema wisselen',
-    'nav.theme_label': 'Thema', 'nav.dark': 'Donker', 'nav.light': 'Licht',
-    'nav.install': 'App installeren',
-    'nav.login': 'Inloggen',
-    'nav.logout': 'Uitloggen',
-    'nav.admin': 'Beheer',
-    'nav.account': 'Account',
-    'nav.profile': 'Profiel',
-    'nav.favorites': 'Favorieten',
-    'nav.new_post': 'Nieuwe post',
-    'nav.language': 'Taal',
-    'nav.notifications': 'Meldingen',
-    'notif.title': 'Meldingen', 'notif.empty': 'Nog geen meldingen.', 'notif.someone': 'Iemand', 'notif.followed': 'volgt je nu', 'notif.liked': 'likete je post', 'notif.boosted': 'boostte je post', 'notif.replied': 'reageerde op', 'notif.reported': 'rapporteerde je bij hun server', 'notif.report_about': 'Over de post', 'notif.report_noreason': 'Geen reden opgegeven.', 'notif.mentioned': 'noemde je in een post', 'blk.title': 'Blokkeren', 'blk.lead': 'Blokkeer een account of een heel domein — hun reacties, likes en posts verdwijnen en nieuwe worden geweigerd.', 'blk.block_btn': 'Blokkeren', 'blk.empty': 'Niks geblokkeerd.', 'blk.unblock': 'Deblokkeren', 'tl.block': 'Blokkeer',
-    'notif.reply': '{actor} reageerde op je reactie', 'notif.comment': '{actor} reageerde op je post', 'notif.like': '{actor} vindt je post leuk',
-    'switch.agenda': 'Agenda',
-    'switch.solo': 'Solo',
-    'switch.circle': 'Cirkels',
-    'switch.grid': 'Grid', 'switch.reader': 'Lezen', 'switch.timeline': 'Tijdlijn', 'read.to_top': 'Terug naar boven', 'read.pinned': 'Vastgepind', 'read.next': 'Volgende', 'read.prev': 'Vorige bericht', 'read.nav': 'Door de berichten', 'read.hint': 'Tik boven- of onderaan om een bericht terug of verder', 'asite.reader_full_page': 'Lezen op desktop: elk bericht een eigen scherm (op mobiel altijd)',
-    'asite.feed_alt': 'Tweede weergave', 'asite.feed_alt_reader': 'Lezen', 'asite.feed_alt_timeline': 'Tijdlijn', 'asite.feed_alt_auto': 'Lezen op mobiel, Tijdlijn op desktop',
-    'switch.reader_solo_only': 'Lezen kan alleen in Solo — in de cirkel staan berichten van anderen',
-    'postnav.newer': 'Nieuwer',
-    'postnav.older': 'Ouder',
-    'postnav.newest': 'Nieuwste post',
-    'postnav.oldest': 'Oudste post',
-    'footer.subscribe_cta': 'Blijf op de hoogte',
-    'footer.subscribe': 'Inschrijven',
-    'footer.install': 'Installeer app',
-    'common.email_placeholder': 'jouw@email.nl',
-    'common.back_to_admin': '← Beheer',
-    // Agenda (publiek)
-    'agenda.title': 'Agenda',
-    'agenda.empty': 'Geen aangekondigde evenementen op dit moment.',
-    'agenda.tickets': 'Tickets',
-    'agenda.notify_h': 'Mis geen evenement',
-    'agenda.notify_sub': 'Laat je e-mail achter en je krijgt een seintje bij een nieuw evenement. Uitschrijven kan altijd.',
-    'agenda.notify_btn': 'Houd me op de hoogte',
-    'agenda.msg_done': '✓ Je staat op de lijst — je hoort het zodra er een evenement wordt aangekondigd.',
-    'agenda.msg_check': '✉ Check je mail om je aanmelding te bevestigen.',
-    // Downloads (publiek)
-    'downloads.title': 'Downloads',
-    'downloads.sub': 'Gratis te downloaden — laat je e-mailadres achter en je krijgt het bestand.',
-    'downloads.empty': 'Er staan momenteel geen downloads klaar.',
-    'downloads.btn': '⬇ Download',
-    // Beheer-dashboard
-    'admin.title': 'Beheer',
-    'adash.prem_active': 'Premium actief',
-    'adash.prem_unlinked': 'Premium niet gekoppeld',
-    'adash.prem_layeroff': 'Premium-laag uit',
-    'admin.tagline_solo': 'Solo-modus — jouw site.',
-    'admin.tagline_cirkels': 'Cirkels-modus: jouw site, verbonden met de fediverse.',
-    'admin.b_paid': 'Betaalde posts', 'admin.b_push': 'Notificaties', 'admin.back': 'Terug naar Beheer',
-    'push.t': 'Notificaties', 'push.intro': 'Krijg een melding op dit apparaat bij nieuwe volgers, reacties en berichten, ook als de site niet open staat. Versleuteld tot in je browser; wij sturen zo min mogelijk inhoud mee.', 'push.unavailable': 'Push is op deze server niet beschikbaar (sleutel kon niet worden aangemaakt of de dependency ontbreekt).', 'push.unsupported': 'Deze browser ondersteunt geen push-notificaties.', 'push.ios_hint': 'Op iPhone/iPad werkt dit alleen als de site op je beginscherm staat: deel-knop, dan "Zet op beginscherm", en open de site daarna vanaf daar.', 'push.this_device': 'Dit apparaat:', 'push.checking': 'controleren…', 'push.state_on': 'meldingen staan aan', 'push.state_off': 'meldingen staan uit', 'push.state_denied': 'geblokkeerd in de browserinstellingen', 'push.state_unknown': 'status onbekend', 'push.state_unsupported': 'niet ondersteund', 'push.enable': 'Zet aan op dit apparaat', 'push.disable': 'Zet uit', 'push.test': 'Stuur testmelding', 'push.what': 'Waarvoor wil je een melding?', 'push.a_follow': 'Nieuwe volger', 'push.a_reply': 'Reactie of vermelding', 'push.a_like': 'Waardering (ster)', 'push.a_boost': 'Boost', 'push.a_dm': 'Privébericht', 'push.saved': 'Opgeslagen.', 'push.devices': 'Gekoppelde apparaten', 'push.device': 'Apparaat', 'push.since': 'sinds', 'push.remove': 'Verwijder', 'push.enable_failed': 'aanzetten mislukt', 'push.on_short': 'Word supporter',
-    'push.n_follow_t': 'Nieuwe volger', 'push.n_follow_b': '{who} volgt je nu', 'push.n_folreq_t': 'Volgverzoek', 'push.n_folreq_b': '{who} wil je volgen — jij beslist', 'push.n_reply_t': 'Reactie op "{title}"', 'push.n_mention_t': 'Vermelding', 'push.n_dm_t': 'Privébericht', 'push.n_dm_b': 'Nieuw bericht van {who}', 'push.n_like_t': 'Nieuwe waardering', 'push.n_like_b': '{who} waardeerde "{title}"', 'push.n_boost_t': 'Geboost', 'push.n_boost_b': '{who} boostte "{title}"', 'msg.guard_offer': 'wil je guardian worden. Bespreek dit met je ouders of verzorgers voordat je beslist.', 'msg.guard_accept': 'Accepteer', 'msg.guard_reject': 'Weiger', 'msg.guard_accepted': 'Guardian geaccepteerd. Jullie zijn nu verbonden.', 'msg.guard_rejected': 'Aanvraag geweigerd.', 'msg.guard_failed': 'Dat lukte niet; probeer het opnieuw.', 'msg.guardians_label': 'Jouw guardians', 'msg.waved_at_you': 'zwaaide naar je', 'msg.help_request': 'vroeg om hulp', 'msg.g_available': 'beschikbaar', 'msg.g_away': 'afwezig tot {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Wat leuk!', 'msg.wave_r2': 'Bel me even', 'msg.wave_back': '👋 Terug', 'msg.wave_sent': 'Zwaai verstuurd.', 'msg.reply_sent': 'Antwoord verstuurd.', 'msg.reply_failed': 'Het antwoord kon niet verstuurd worden.', 'msg.reply_empty': 'Een leeg antwoord versturen kan niet.', 'guardian.feed_title': 'Van je wards', 'guardian.feed_sub': 'Meelezen met wat je wards plaatsen. Alleen kijken.', 'guardian.follow_title': 'Volgverzoeken', 'guardian.follow_sub': 'Iemand wil een van je wards volgen. Jij beslist.', 'guardian.wave': '👋 Zwaai', 'guardian.waved': '👋 verstuurd', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards beheren en hulpverzoeken opvangen.', 'guardian.acting_as': 'Je handelt als', 'guardian.help_title': 'Hulpverzoeken', 'guardian.help_sub': 'Als een ward de reddingsboei gebruikt, verschijnt het hier.', 'guardian.help_empty': 'Geen hulpverzoeken. Mooi zo.', 'guardian.adopt_title': 'Ward adopteren', 'guardian.adopt_sub': 'Vul de handle van het kind in (@kind@server.eu). Ze krijgen een aanvraag in hun Klonkt die ze accepteren.', 'guardian.adopt_label': 'Handle van de ward', 'guardian.adopt_btn': 'Verstuur aanvraag', 'guardian.pending_title': 'Verzonden aanvragen', 'guardian.pending_sub': 'Wacht tot de ward accepteert.', 'guardian.wards_title': 'Mijn wards', 'guardian.play_propose': 'Afspelen voorstellen', 'guardian.play_on': 'Afspelen: aan', 'guardian.play_off': 'Afspelen: uit', 'guardian.gated_title': 'Instelling voorgesteld', 'guardian.gated_line_on': '{who} wil linkvoorbeelden AANzetten voor {ward}.', 'guardian.gated_line_off': '{who} wil linkvoorbeelden UITzetten voor {ward}.', 'guardian.gated_agree': 'Eens', 'guardian.gated_disagree': 'Oneens', 'guardian.avail_available': 'Beschikbaar', 'guardian.avail_away': 'Afwezig tot {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians van dit kind', 'guardian.panel_guards_remote': 'Dit kind woont op een andere server; de beschikbaarheid wordt daar bijgehouden.', 'guardian.lapse_propose': 'Voorstel: loslaten bij afwezigheid', 'guardian.lapse_line': '{who} antwoordt niet meer als guardian van {ward}.', 'guardian.lapse_tally': '{n} van {need} akkoord; sluit {date}; het venster loopt altijd vol.', 'guardian.lapse_note': 'Elk teken van leven van hen annuleert dit meteen. Niets hier is straf.', 'guardian.lapse_agree': 'Eens', 'guardian.lapse_disagree': 'Oneens', 'guardian.voted': 'Je hebt gestemd', 'guardian.away_title': 'Even afwezig', 'guardian.away_sub': 'Meld je wards dat je er even niet bent. Besluiten wachten niet op je, en een enkel antwoord brengt je meteen terug.', 'guardian.away_week': 'Een week', 'guardian.away_month': 'Een maand', 'guardian.away_done': 'Je wards weten dat je afwezig bent tot {date}.', 'guardian.away_msg': 'Ik ben afwezig als je guardian tot {date}. Je andere guardians zijn er voor je.', 'guardian.release_title': '{who} loslaten?', 'guardian.release_effect': 'Je stopt als guardian. Je ziet hun berichten niet meer, je krijgt geen hulpvragen meer van ze, en je beoordeelt geen volgverzoeken meer voor ze. Terugkomen kan alleen met een nieuwe aanvraag die zij accepteren.', 'guardian.release_local': 'Hun server en de andere guardians krijgen dit door, dus daarna sta jij ook bij hen niet meer als guardian.', 'guardian.release_step_down': 'Zij houden hun andere guardians, dus zij blijven een ward.', 'guardian.release_last': 'Je bent hun laatste guardian. Dat is emancipatie, en volgens FEP-633c 3.4 is dat niet aan een guardian alleen: daar horen drie instemmende volwassenen bij, of een meerderheid met twee getuigen. Deze knop kan dat dus niet: je blijft hun guardian tot dat geregeld is.', 'guardian.release_unknown': 'We konden hun server niet bereiken, dus we weten niet of jij hun laatste guardian bent.', 'guardian.release_yes': 'Ja, loslaten', 'guardian.release_no': 'Nee, toch niet', 'guardian.settings_title': 'Instellingen', 'guardian.panel_open': 'Bekijken', 'guardian.panel_close': 'Sluiten', 'guardian.panel_help': 'Hulpvragen van dit kind', 'guardian.panel_help_empty': 'Nog geen hulpvragen.', 'guardian.panel_follow': 'Volgverzoeken', 'guardian.follow_out_line': 'wil {who} volgen', 'guardian.panel_follow_empty': 'Geen openstaande volgverzoeken.', 'guardian.panel_posts': 'Recente berichten', 'guardian.panel_posts_empty': 'Nog niets te zien.', 'guardian.panel_actions': 'Acties', 'guardian.badge_help': 'hulpvragen', 'guardian.badge_follow': 'volgverzoeken', 'guardian.badge_follow_one': 'volgverzoek', 'guardian.wards_empty': 'Nog geen wards. Adopteer er hierboven een.', 'guardian.push_title': 'Meldingen', 'guardian.push_sub': 'Ontvang een melding bij een hulpverzoek of voogdij-antwoord, ook als de app dicht is.', 'guardian.push_on': 'Zet meldingen aan', 'guardian.push_off': 'Meldingen staan aan; tik om uit te zetten', 'guardian.sent': 'Aanvraag verstuurd. Zie hieronder bij Verzonden aanvragen.', 'guardian.sent_retry': 'Aanvraag opgeslagen; we blijven proberen te bezorgen.', 'guardian.sending': 'Versturen…', 'guardian.not_found': 'Die handle konden we niet vinden.', 'guardian.failed': 'Mislukt', 'guardian.network': 'Netwerkfout.', 'guardian.pending': 'wacht op antwoord', 'guardian.active': 'actief', 'guardian.retract': 'Intrekken', 'guardian.release': 'Loslaten', 'guardian.embeds_on': 'Linkvoorbeelden: aan', 'guardian.embeds_off': 'Linkvoorbeelden: uit', 'guardian.embeds_propose': 'Linkvoorbeelden voorstellen', 'guardian.embeds_waiting': 'wacht op de andere guardians', 'guardian.prop_line': 'Voorstel {what} {value}: {status}', 'guardian.prop_embeds': 'linkvoorbeelden', 'guardian.prop_play': 'afspelen', 'guardian.prop_on': 'aan', 'guardian.prop_off': 'uit', 'guardian.prop_st_open': 'wacht op de andere guardians', 'guardian.prop_st_accepted': 'aangenomen', 'guardian.prop_st_rejected': 'afgewezen', 'guardian.prop_st_expired': 'verlopen zonder genoeg stemmen', 'guardian.panel_guards_far': 'Beschikbaarheid wordt op hun server bijgehouden.', 'guardian.release_confirm': '{who} loslaten?\n\nJe stopt dan als guardian. Je ziet hun berichten niet meer, je krijgt geen hulpverzoeken meer van ze, en je kunt volgverzoeken niet meer voor ze beoordelen.\n\nTerugkomen kan alleen met een nieuwe aanvraag die zij accepteren.', 'guardian.open': 'open', 'guardian.accept': 'Accepteer', 'guardian.reject': 'Weiger', 'guardian.complete': 'Voltooien', 'guardian.awaiting_others': 'wacht op de andere partijen', 'guardian.coguard': 'mede-voogdij-aanvraag', 'guardian.push_unavailable': 'Push niet beschikbaar', 'push.n_help_t': 'Hulpvraag', 'push.n_help_b': '{who} vraagt om je hulp', 'push.n_guard_offer_t': 'Voogdij-aanvraag', 'push.n_guard_offer_b': '{who} wil je guardian worden', 'push.n_guard_ward_t': 'Ward geaccepteerd', 'push.n_guard_left_t': 'Een guardian is gestopt', 'push.n_guard_left_b': '{who} is niet langer je guardian', 'push.n_guard_cogleft_t': 'Mede-guardian gestopt', 'push.n_guard_cogleft_b': '{who} heeft de guardianship beeindigd', 'push.n_guard_ward_b': '{who} accepteerde je als guardian', 'push.n_guard_cog_t': 'Mede-voogdij gevraagd', 'push.n_guard_cog_b': 'Er is een guardian-aanvraag voor {who}', 'push.n_guard_folin_t': 'Volgverzoek', 'push.n_guard_folin_b': '{who} wil {ward} volgen', 'push.n_guard_folout_t': 'Je ward wil iemand volgen', 'push.n_guard_folout_b': '{ward} vraagt of het {who} mag volgen', 'guardian.panel_history': 'Geschiedenis ({n})', 'guardian.log_show': 'Toon geschiedenis', 'guardian.log_hide': 'Verberg geschiedenis', 'guardian.ev_offer_rejected': 'Aanbod afgewezen', 'guardian.ev_offer_refused': 'Aanbod geweigerd', 'guardian.ev_committed': 'Guardianship vastgelegd', 'guardian.ev_guardian_left': 'Guardian gestopt', 'guardian.ev_coguardian_left': 'Mede-guardian gestopt', 'guardian.ev_gated_outcome': 'Poort besloten', 'guardian.ev_lapse_opened': 'Vrijgave voorgesteld', 'guardian.evr_not_a_teapot': 'de kandidaat is zelf een ward', 'guardian.help_archive': '{n} afgehandeld', 'guardian.help_archive_hide': 'verbergen', 'guardian.help_former_ward': 'Niet meer jouw ward. Hun andere guardians zijn er nog voor ze.', 'guardian.warn_reversible': 'Wat hier doorheen komt, komt niet meer terug. Je kunt deze instelling later weer dichtzetten — wat je kind gezien heeft niet.', 'guardian.warn_irreversible': 'Dit is niet terug te draaien. Hierna beslist zij het zelf, en jullie kunnen dat niet meer terugnemen.', 'guardian.warn_unknown': 'Wij kennen deze instelling niet, dus we weten niet wat er doorheen komt of hoe ver het reikt. Vraag het degene die het voorstelde voordat je ja zegt.', 'guardian.warn_decides': 'JOUW ANTWOORD BESLIST DIT. Met jou erbij is de drempel gehaald en gaat het meteen in.', 'guardian.warn_not_last': 'Er moet daarna nog iemand antwoorden voordat dit ingaat.', 'guardian.warn_tally_elsewhere': 'Hoeveel guardians al geantwoord hebben zien wij niet — dat telt de server van het kind. Jouw ja kan de doorslag geven.', 'guardian.warn_go': 'Ja, stel dit voor', 'guardian.warn_back': 'Nee, toch niet', 'guardian.help_pick': 'Ik kijk hiernaar', 'guardian.help_close': 'Markeer als afgehandeld', 'guardian.help_picked_by': '{who} kijkt hiernaar', 'guardian.help_handled_by': 'Afgehandeld door {who}', 'guardian.help_handled_note': 'Dit blijft staan. Leeft de vraag nog, dan stelt het kind hem opnieuw.', 'guardian.help_close_ask': 'Weet je het zeker? Dit is niet terug te draaien. Speelt het toch nog, dan vraagt het kind opnieuw om hulp.', 'guardian.help_close_yes': 'Ja, afgehandeld', 'guardian.help_just_now': 'zojuist', 'guardian.help_hours': '{n} uur geleden', 'guardian.help_days': '{n} dagen geleden', 'guardian.gate_unavailable': 'nog niet beschikbaar', 'guardian.gate_planned_note': 'Dit bestaat nog niet op deze server.', 'guardian.gates_summary': '{n} poorten - {on} aan, {wait} wachten', 'guardian.gates_show': 'Toon poorten', 'guardian.gates_hide': 'Verberg poorten', 'guardian.gate_images': 'Plaatjes', 'guardian.gate_messages': 'Berichten', 'guardian.gate_asked': 'Je kind vroeg hier zelf om.', 'guardian.gate_replies': 'Antwoorden in een gesprek', 'guardian.gate_compose': 'Zelf posten', 'guardian.gate_music': 'Muziek', 'guardian.gate_quoteCards': 'Quote-kaarten', 'guardian.gate_customEmoji': 'Eigen emoji', 'guardian.gate_publicProfile': 'Publiek zichtbaar', 'guardian.gate_accountMove': 'Verhuizen', 'guardian.gate_independence': 'Zelfstandig worden', 'guardian.gate_externalThreads': 'Reacties van onbekenden', 'guardian.gate_externalEmbeds': 'Linkvoorbeelden', 'guardian.gate_externalPlayback': 'Afspelen in de app', 'guardian.gate_follows': 'Volgverzoeken', 'guardian.gate_following': 'Zelf iemand volgen', 'guardian.gate_kind_setting': 'stand', 'guardian.gate_kind_perRequest': 'per verzoek', 'guardian.gate_kind_handover': 'draagt gezag over', 'guardian.gate_default_off': 'uit (nog niets over besloten)', 'guardian.gate_unknown': 'onbekend', 'guardian.gate_always': 'Altijd', 'guardian.gate_threshold': '{need} van {of} guardians', 'guardian.gate_threshold_unknown': 'drempel onbekend (ander domein)', 'guardian.gate_irreversible': 'niet terug te draaien', 'guardian.gate_waiting': '{n} wacht op jullie', 'guardian.gate_blocked': 'kan pas als {what} aanstaat', 'guardian.gate_propose_open': 'Voorstellen: openzetten', 'guardian.gate_propose_close': 'Voorstellen: dichtzetten', 'guardian.gate_propose': 'Voorstellen', 'push.n_gate_ask_t': 'Je antwoord is nodig', 'push.n_gate_ask_b': 'Voorstel voor {who}: {wat} {stand}', 'push.n_gate_done_t': 'Besluit genomen', 'push.n_gate_done_b': '{wat} {stand} voor {who}: {uitkomst}', 'push.n_test_t': 'Klonkt-testnotificatie', 'push.n_test_b': 'Werkt. Zo komen meldingen binnen op dit apparaat.',
-    'apaid.t': 'Betaalde posts', 'apaid.intro': 'Koppel je eigen Patreon-campagne. Supporters ontgrendelen betaalde posts met een passkey, zonder account en zonder cookie. Wij bewaren geen namen of e-mailadressen van supporters, alleen het versleutelde token van jouw campagne.', 'apaid.saved': 'Opgeslagen.', 'apaid.nokey': 'Let op: de encryptiesleutel kon niet worden aangemaakt of gelezen (schrijfrechten op de opslagmap?). Zonder sleutel kunnen secrets niet veilig worden opgeslagen.', 'apaid.status': 'Status:', 'apaid.connected': 'verbonden', 'apaid.campaign': 'campagne', 'apaid.configured': 'ingesteld, nog niet verbonden (vul een token in)', 'apaid.notyet': 'nog niet ingesteld', 'apaid.redirect_h': 'Zet deze redirect-URI in je Patreon-client', 'apaid.redirect_p': 'Bij je Patreon API-client, onder Redirect URIs, moet exact deze regel staan. Klopt hij niet, dan geeft Patreon een foutmelding in plaats van je supporters terug te sturen.', 'apaid.copy': 'Kopieer', 'apaid.copied': 'Gekopieerd', 'apaid.client_id': 'Patreon client id', 'apaid.client_secret': 'Patreon client secret', 'apaid.keep': 'Leeg laten = huidige waarde behouden.', 'apaid.campaign_id': 'Campagne-id', 'apaid.public_page': 'Openbare Patreon-pagina', 'apaid.public_help': 'De link waar bezoekers supporter kunnen worden. Getoond als "Word supporter" wanneer iemand nog niet doneert.', 'apaid.access': 'Creator access token', 'apaid.refresh': 'Creator refresh token', 'apaid.token_help': 'De access + refresh token krijg je op je Patreon API-clientpagina. Wij versleutelen ze en verversen automatisch.', 'apaid.min_eur': 'Standaard-steunbedrag voor een betaalde post (euro)', 'apaid.save': 'Opslaan', 'apaid.disconnect': 'Koppeling verwijderen', 'apaid.disconnect_confirm': 'Patreon-koppeling verwijderen?', 'apaid.unchanged': 'blijft ongewijzigd',
-    'pgate.h': 'Voor supporters', 'pgate.sub': 'Deze post is voor supporters van deze site. Word supporter en ontgrendel hem daarna met een passkey. Geen account op deze site, geen cookie.', 'pgate.sub_cents': 'Deze post is voor supporters van deze site (vanaf €{eur} per maand op Patreon). Word supporter en ontgrendel hem daarna met een passkey. Geen account op deze site, geen cookie.', 'pgate.join': 'Word supporter op Patreon', 'pgate.unlock_have': 'Al supporter? Ontgrendelen', 'pgate.unlock': 'Ontgrendelen met Patreon', 'pgate.join_short': 'Word supporter', 'pgate.confirm': 'Bevestig met je passkey…', 'pgate.failed': 'Ontgrendelen mislukt. Probeer opnieuw.', 'pgate.error': 'Er ging iets mis. Probeer opnieuw.',
-    'ppk.t': 'Maak je passkey', 'ppk.h': 'Je bent supporter, mooi.', 'ppk.sub': 'Maak nu een passkey aan. Die wordt je sleutel voor betaalde posts, zonder account en zonder cookie. We bewaren geen naam of e-mailadres.', 'ppk.make': 'Maak passkey', 'ppk.unsupported': 'Passkeys worden niet ondersteund in deze browser.', 'ppk.follow': 'Volg de vraag van je apparaat…', 'ppk.done': 'Gelukt. Je passkey is aangemaakt.', 'ppk.failed': 'Aanmaken mislukt ({err}). Probeer opnieuw.', 'ppk.cancelled': 'Geannuleerd.',
-    'pres.t': 'Ontgrendelen', 'pres.notpatron_h': 'Nog geen supporter', 'pres.notpatron_p': 'Je bent (nog) geen actieve supporter van deze site op Patreon. Word supporter en probeer het daarna opnieuw vanaf de post.', 'pres.tier_h': 'Een niveau hoger nodig', 'pres.tier_p': 'Deze post vraagt vanaf €{need}. Jouw steun is nu €{have}. Verhoog je steun en probeer opnieuw.', 'pres.expired_h': 'Aanvraag verlopen', 'pres.expired_p': 'Deze ontgrendel-link is verlopen of al gebruikt. Ga terug naar de post en probeer het opnieuw.', 'pres.declined_h': 'Ontgrendelen afgebroken', 'pres.declined_p': 'Er is niets gekoppeld. Je kunt het opnieuw proberen vanaf de post.', 'pres.join': 'Word supporter op Patreon', 'pres.back_post': 'Terug naar de post', 'pres.back_site': 'Terug naar de site',
-    'admin.tagline_hub': 'Hub-modus — bedrijfssite met gebruikers, elk hun eigen Klonkt Hub.',
-    'admin.b_sites': '🌐 Sites', 'admin.b_users': '👥 Gebruikers', 'admin.b_audio': '🎵 Audio',
-    'admin.b_media': '🎬 Media', 'admin.t_media': 'Media', 'admin.media_images': 'Afbeeldingen', 'admin.media_videos': "Video's", 'admin.videos_count': "video's", 'admin.videos_empty': "Nog geen video's geupload.", 'admin.videos_del_confirm': 'Deze video verwijderen?', 'admin.media_count': 'afbeeldingen', 'admin.media_unused': 'ongebruikt', 'admin.media_cleanup': 'Ongebruikt opruimen', 'admin.media_cleanup_confirm': 'Alle ongebruikte afbeeldingen verwijderen?', 'admin.media_empty': 'Nog geen afbeeldingen geüpload.', 'admin.media_copy': 'Kopieer URL', 'admin.media_del_confirm': 'Deze afbeelding verwijderen?',
-    'admin.b_playlists': '📃 Playlists', 'admin.b_comments': '💬 Reacties', 'admin.b_seo': '🔎 SEO',
-    'admin.b_listeners': 'Luisteraars',
-    'alis.lis_title': 'Luisteraars',
-    'alis.lis_intro': 'Accounts die je BIBLIOTHEEK volgen. Ze krijgen je muziek, en met opzet niet je gewone posts — wie zich op een platenkast abonneert heeft niet om de Krant gevraagd.',
-    'alis.lis_empty': 'Nog niemand volgt de bibliotheek.',
-    'alis.lis_since': 'Sinds',
-    'alis.lis_last': 'Laatste bezorging',
-    'alis.lis_never': 'nog niets bezorgd',
-    'alis.lis_count': 'luisteraar(s)',
-    'alis.lis_error': 'bezorging mislukt',
-    'ple.titel': 'Titel *',
-    'ple.artiest': 'Artiest',
-    'ple.jaar': 'Jaar',
-    'ple.type': 'Type',
-    'ple.k_album': 'Album (genummerd)',
-    'ple.k_playlist': 'Playlist (track-covers)', 'ple.k_mixtape': 'Mixtape (bandje: alleen vooruit en achteruit)',
-    'ple.uitgave': 'Uitgavedatum',
-    'ple.mb_release': 'MusicBrainz release-id',
-    'ple.cover': 'Cover',
-    'ple.cover_kies': 'Foto kiezen…',
-    'ple.cover_url': 'https://… of upload',
-    'ple.tracks_in': 'Tracks in playlist',
-    'ple.sleep_hint': '(sleep ⠿ om te ordenen)',
-    'ple.beschikbaar': 'Beschikbare tracks',
-    'ple.zoek': 'Zoek…',
-    'ple.geen_res': 'Geen resultaten.',
-    'ple.leeg_sel': 'Klik tracks rechts om toe te voegen.',
-    'ple.t_edit': 'Playlist bewerken',
-    'ple.t_new': 'Nieuwe playlist',
-    'ple.dialoog': 'Playlist-editor',
-    'ple.sluiten': 'Sluiten',
-    'ple.annuleren': 'Annuleren',
-    'ple.opslaan': 'Opslaan',
-    'ple.bezig_opslaan': 'Opslaan…',
-    'ple.aanmaken': 'Aanmaken',
-    'ple.versleep': 'Verslepen',
-    'ple.verwijder': 'Verwijderen',
-    'ple.geen_audio': 'Track heeft geen audio-bestand',
-    'ple.e_geen_tracks': 'Geen audio-tracks beschikbaar. Upload eerst tracks via Beheer → Audio.',
-    'ple.e_tracks': 'Tracks ophalen mislukt',
-    'ple.e_opslaan': 'Opslaan mislukt: ',
-    'ple.e_mislukt': 'Mislukt: ',
-    'ple.e_alleen_afb': 'Alleen afbeeldingen',
-    'ple.bezig': 'Uploaden…',
-    'ple.e_upload': 'Upload mislukt',
-    'admin.b_settings': '⚙️ Instellingen', "mig.title": "Migreren", "mig.plan_new": "op je nieuwe Klonkt", "mig.plan_from_old": "Je kijkt naar de instantie die VERTREKT. Stap 1, 3 en 4 doe je op je nieuwe Klonkt.", "mig.plan_title": "Zo gaat een verhuizing", "mig.plan_hint": "De volgorde is niet vrijblijvend. Stap 2 moet voor stap 3, want je oude Klonkt geeft niets af aan een adres dat hij niet als zijn opvolger kent. Werk je met een zip, dan mag je 2 juist tot het laatst bewaren: een bestand vraagt niemand om toestemming.", "mig.plan_here": "hier", "mig.plan_old": "op je oude Klonkt", "mig.plan_1": "Je vorige account koppelen", "mig.plan_1_why": "Doe je dit niet, dan weigert je oude Klonkt de verhuizing.", "mig.plan_2": "De verhuizing aankondigen", "mig.plan_2_why": "Je volgers verhuizen mee. Daarna gaat je oude account op slot: posten, volgen en reageren kan daar niet meer.", "mig.plan_3": "Je berichten en muziek ophalen", "mig.plan_3_why": "Rechtstreeks van je oude Klonkt, of via een zip als die al offline is.", "mig.plan_4": "Je volglijst terugzetten", "mig.plan_5": "Later: als je het oude domein opzegt", "mig.plan_5_why": "Verwijder daar eerst je oude berichten. Dan verdwijnt een boost van iemand anders netjes, in plaats van te blijven staan als een kapotte kaart met een dode link. Haal daarna hierboven bij stap 1 je vorige account weg: dat adres beheer je dan niet meer. Hier zit nog geen knop voor.", "mig.follows_note": "Of plak de inhoud van je CSV hierboven. Het bestand wint als je allebei invult.", "asite.moved_to_migrate": "Aliassen en verhuizen staan nu bij Migreren, samen met exporteren, importeren en ophalen.", "mig.alias_title": "Stap 1: je vorige account koppelen", "mig.alias_hint": "Vertel welk account vroeger van jou was. Je oude Klonkt kijkt hiernaar voordat hij je volgers laat verhuizen, en de ophaalknop hieronder heeft het ook nodig.", "mig.alias_label": "Je vorige accounts, één per regel", "mig.alias_note": "Zoals je ze aan iemand zou geven: @jij@mastodon.social. Maximaal vijf.", "mig.alias_btn": "Opslaan", "mig.move_title": "Stap 2: de verhuizing aankondigen", "mig.move_hint": "Dit vertelt al je volgers dat je account voortaan ergens anders woont. Zij verhuizen mee, en dit account gaat daarna op slot: posten, volgen, liken en reageren kan niet meer.", "mig.move_label": "Je nieuwe adres", "mig.move_warn": "Dit is de enige knop op deze pagina die je niet kunt terugdraaien. Je oude account gaat er daarna van op slot. Werk je met de ophaalknop, dan MOET dit eerst: anders geeft je oude Klonkt niets af.", "mig.move_btn": "Verhuizing aankondigen", "mig.move_confirm": "Dit kondigt je verhuizing aan bij al je volgers en zet dit account op slot. Zeker weten?", "mig.move_done": "Dit account is al verhuisd naar", "mig.r_links_fixed": "berichten waarvan de links nu naar hier wijzen", "mig.r_posts_updated": "berichten gerepareerd (plaatjes lokaal gemaakt)", "mig.r_tracks_updated": "nummers aangevuld", "mig.r_tracks_missing": "{n} nummer(s) zijn NIET aangekomen, want het geluidsbestand ontbrak. Ze zijn met opzet niet aangemaakt: een nummer dat in de lijst staat en niet afspeelt is erger dan een nummer dat er niet is.", "mig.c_tracks": "nummers", "mig.c_playlists": "playlists", "mig.audio_missing": "Let op: van {n} nummer(s) is het geluidsbestand niet te vinden. Die gaan niet mee.", "mig.audio_none": "Let op: deze site heeft muziek, maar er gaat geen enkel nummer mee. Waarschijnlijk staan de bestanden ergens anders dan de database denkt.", "mig.pull_title": "Ophalen bij je oude Klonkt", "mig.pull_hint": "Ben je al verhuisd, dan haalt deze Klonkt je berichten rechtstreeks bij de oude op. Je hoeft niets over te typen: dat je verhuisd bent is het bewijs.", "mig.pull_source": "Je oude account", "mig.pull_source_hint": "Overgenomen uit stap 1. Klopt het niet, pas het hier aan.", "mig.pull_btn": "Berichten ophalen", "mig.pull_done": "Opgehaald", "mig.pull_failed": "Ophalen lukte niet", "mig.r_blocks": "blokkades overgenomen", "mig.state": "{n} berichten hebben een verwijzing van hun oude adres.", "mig.state_done": "De lijst is compleet.", "mig.state_busy": "De lijst is nog niet compleet.", "mig.e_no_source": "Er is geen oud account bekend. Vul het adres hierboven in.", "mig.e_unreachable": "De oude Klonkt is niet bereikbaar.", "mig.e_not_moved_here": "Dat account is niet naar hier verhuisd. Kondig de verhuizing eerst aan op je oude Klonkt.", "mig.e_no_backreference": "Dit account zegt nergens dat dat oude account van jou was. Vul het eerst in bij stap 1 hierboven, anders kan je oude Klonkt niet zien dat jij het bent.", "mig.e_no_outbox": "De oude Klonkt heeft geen berichtenlijst.", "mig.e_partial": "Halverwege gestopt. Wat binnen is blijft staan; probeer het nog eens.", "mig.e_config": "Deze Klonkt weet zijn eigen adres niet.", "mig.e_crash": "Er ging iets onverwachts mis.", "mig.e_points_at": "Dat account wijst naar:", "mig.lead": "Neem je berichten, muziek en foto’s mee naar een andere Klonkt, of haal ze hiernaartoe.", "mig.export_title": "Meenemen", "mig.export_hint": "Dit maakt een zip met je berichten, de reacties erop, en de media die erbij hoort.", "mig.c_posts": "berichten", "mig.c_replies": "reacties", "mig.c_media": "mediabestanden", "mig.c_following": "volgingen", "mig.c_size": "groot", "mig.missing": "Let op: van {n} mediaverwijzing(en) staat het bestand niet meer op schijf. Die gaan dus niet mee.", "mig.too_big": "Dit archief is te groot voor de webinterface. Gebruik scripts/export-archive.mjs op de server.", "mig.export_btn": "Archief downloaden (.zip)", "mig.export_none": "Er valt nog niets te exporteren.", "mig.import_title": "Hierheen halen", "mig.import_hint": "Kies je bestand. Je krijgt eerst te zien wat er zou gebeuren; er verandert nog niets.", "mig.file_label": "Je archiefbestand (.zip)", "mig.overwrite_label": "Berichten die er al staan vervangen", "mig.overwrite_hint": "Normaal blijft alles wat hier al staat met rust. Zet je dit aan, dan wordt een bericht met dezelfde naam vervangen, ook als het iets anders was. Dat komt niet terug.", "mig.check_btn": "Controleren", "mig.check_hint": "Dit verandert nog niets. Je krijgt eerst te zien wat er zou gebeuren.", "mig.r_dry": "Wat er zou gebeuren", "mig.r_done": "Ge\u00efmporteerd", "mig.r_would": "zouden erbij komen", "mig.r_imported": "erbij gekomen", "mig.r_skipped": "overgeslagen (staan er al)", "mig.r_overwritten": "overschreven", "mig.r_media": "mediabestanden", "mig.r_media_missing": "media ontbrak in het archief", "mig.r_new_ids": "Dit archief komt van een ander webadres. Je berichten staan hier dus op een nieuw adres. Andere servers weten dat nog niet, dus reacties en boosts die daar naar het oude adres wijzen blijven daar staan.", "mig.r_confirm_hint": "Ziet het er goed uit? Kies hetzelfde bestand nog een keer om het echt te doen.", "mig.r_confirm_btn": "Nu echt importeren", "mig.follows_title": "Wie je volgt", "mig.follows_hint": "Je volglijst zit ook in het archief, en je kunt hem los downloaden en terugzetten bij Connect.", "mig.follows_btn": "Naar Connect", "admin.b_migrate": "\ud83d\udce6 Migreren", 'admin.b_newpost': '✍️ Nieuwe post', 'admin.b_look': '🎨 Uiterlijk',
-    'admin.t_admin': 'Beheer', 'admin.t_settings': 'Instellingen', 'admin.t_audio': 'Audiotracks', 'admin.t_epk': 'Perskit bewerken', 'admin.t_newsletter': 'Nieuwsbrief', 'admin.t_playlists': 'Afspeellijsten', 'admin.t_seo': 'SEO', 'admin.t_shows': 'Agenda', 'admin.t_sites': 'Sites', 'admin.t_newsite': 'Nieuwe site', 'admin.t_editsite': 'Bewerken: {title}', 'admin.t_stats': 'Statistieken', 'admin.t_updates': 'Updates', 'admin.t_users': 'Gebruikers', 'admin.t_hub': 'Mijn Klonkt Hub', 'admin.t_manual': 'Handleiding', 'aset.premium_gate': '{feature} is premium — koppel Patreon in Beheer → Instellingen.',
-    'admin.b_makesite': '🎨 Maak je site aan', 'admin.b_circle': '🔗 Cirkel', 'admin.b_stats': '📊 Statistieken',
-    'admin.b_newsletter': '✉️ Nieuwsbrief', 'admin.b_perskit': '📰 Perskit', 'admin.b_downloads': '⬇ Downloads',
-    'admin.b_linkbio': '🔗 Link-in-bio', 'admin.b_agenda': '📅 Agenda',
-    'admin.b_updates': '🔄 Updates', 'admin.b_help': '📖 Handleiding', 'admin.b_fediverse': 'Mijn reacties',
-    'admin.st_users': 'Gebruikers', 'admin.st_sites': 'Sites', 'admin.st_posts': 'Posts', 'admin.st_published': 'Gepubliceerd',
-    'admin.sec_posts': 'Posts', 'admin.sec_sites': 'Sites', 'admin.sec_users': 'Users',
-    'admin.draft': 'Concept', 'admin.edit': 'Bewerken', 'admin.view': 'Bekijk',
-    'admin.th_slug': 'Slug', 'admin.th_title': 'Titel', 'admin.th_owner': 'Eigenaar', 'admin.th_created': 'Aangemaakt',
-    'admin.th_username': 'Gebruikersnaam', 'admin.th_email': 'E-mail', 'admin.th_role': 'Rol', 'admin.th_joined': 'Lid sinds',
-    // Welkom / first-run
-    'welcome.title': 'Welkom bij Klonkt',
-    'welcome.tagline': 'Een zelf-gehost publicatieplatform.',
-    'welcome.have_account': 'Al een account? Inloggen',
-    'welcome.note': 'Maak je beheerdersaccount aan om te beginnen — daarna is registratie gesloten.',
-    'welcome.nosite': 'Hoi {user}! Er is nog geen site ingesteld.',
-    // Auth (login/register)
-    'auth.admin_login_title': 'Beheerder inloggen',
-    'auth.username_or_email': 'Gebruikersnaam of e-mail',
-    'auth.password': 'Wachtwoord',
-    'auth.forgot': 'Wachtwoord vergeten?',
-    'auth.public_sub': 'Log in om te reageren en je favorieten te bewaren.',
-    'auth.admin_box_q': 'Beheerder?',
-    'auth.admin_box_sub': 'Log in met gebruikersnaam & wachtwoord',
-    'auth.create_admin': 'Beheerder aanmaken',
-    'auth.reg_intro': 'Eerste keer opzetten — maak je beheerdersaccount aan. Dit kan maar één keer.',
-    'setup.title': 'Stel je Klonkt in',
-    'setup.intro': 'Welkom! Even je site klaarzetten — dit duurt een minuutje. Kies eerst je taal.',
-    'setup.lang_label': 'Taal',
-    'setup.f_sitename': 'Naam van je site',
-    'setup.sitename_ph': 'bijv. jouw artiestennaam',
-    'setup.submit': 'Mijn site aanmaken',
-    'setup.username_note': 'Dit wordt je adres op de fediverse en kun je later niet meer wijzigen:',
-    'changelog.title': 'Wijzigingen', 'changelog.empty': 'Geen wijzigingenoverzicht beschikbaar.',
-    'auth.f_username': 'Gebruikersnaam (3-32 tekens, letters/cijfers/_-)',
-    'auth.f_email': 'E-mail',
-    'auth.f_password': 'Wachtwoord (min 8 tekens)',
-    // Bottom-tab (mobiel)
-    'tab.home': 'Home', 'tab.search': 'Zoek', 'tab.write': 'Schrijven', 'tab.profile': 'Profiel',
-    // Reacties + gerelateerd (post-pagina)
-    'comments.heading_one': '{n} reactie', 'comments.heading_other': '{n} reacties',
-    'comments.empty': 'Nog geen reacties.',
-    'fedi.heading': 'Vanuit de fediverse', 'fedi.likes': 'sterren', 'fedi.boosts': 'boosts', 'fedi.replies': 'Reacties uit de fediverse',
-    'fedi.reply': 'Reageer', 'fedi.reply_ph': 'Je antwoord aan de fediverse…', 'fedi.send': 'Versturen', 'fedi.you': 'Jij',
-    'fedi.remote_title': 'Reageer via de fediverse', 'fedi.follow_heading': 'Volgen via de fediverse', 'fedi.profile_follow': 'Volg via de fediverse', 'profile.since': 'Op Klonkt sinds', 'profile.free': 'Gratis', 'fedi.follow_intro': 'Je staat op het punt te volgen:', 'fedi.follow_btn': 'Volgen', 'fedi.cancel': 'Annuleren', 'fedi.followed_title': 'Volgverzoek verstuurd ✅', 'fedi.followed_done': 'Je volgverzoek is onderweg. Zodra de andere kant het accepteert, verschijnen hun berichten in je tijdlijn.', 'fedi.view_profile': 'Bekijk profiel →', 'fedi.remote_reply': 'Reageer via de fediverse', 'fedi.remote_prompt': 'Je fediverse-adres:', 'fedi.remote_notfound': 'Kon die post niet ophalen. Plak de volledige post-URL:', 'fedi.remote_load': 'Ophalen', 'fedi.remote_replying_to': 'Je reageert op', 'fedi.remote_as': 'Wordt verzonden als {site}.', 'fedi.remote_view_original': 'Bekijk de hele post + reacties op de bron →', 'fedi.remote_reply_short': 'via de fediverse', 'fedi.like_short': 'Like', 'fedi.unlike_short': 'Like intrekken', 'fedi.boost_short': 'Boost', 'fedi.remote_ph': 'jouw server', 'fedi.remote_sent_title': 'Verzonden ✅', 'fedi.remote_sent': 'Je reactie is verstuurd. Hij verschijnt zo bij de originele post op de fediverse, niet op deze pagina. Bekijk hem daar:', 'fedi.reply_where': 'Je reactie verschijnt bij de originele post op de fediverse, niet op deze pagina. Via de link hierboven zie je hem daar.', 'fedi.remote_back': '← Terug naar je site', 'fedi.like_btn': 'Like deze post', 'fedi.or_reply': 'of reageer:', 'fedi.liked_title': 'Geliket', 'fedi.liked_done': 'Je like is onderweg naar de fediverse.', 'fedi.boost_btn': 'Boost deze post', 'fedi.boosted_title': 'Geboost', 'fedi.boosted_done': 'Je boost is onderweg naar de fediverse.', 'fedi.remote_interact': 'Interacteer via de fediverse', 'fedi.report_open': 'Deze post rapporteren', 'fedi.report_where': 'De melding gaat naar de instance en hun moderator(s).', 'fedi.report_ph': 'Wat is er mis? (optioneel)', 'fedi.report_send': 'Rapporteren', 'fedi.reported_title': 'Gerapporteerd', 'fedi.reported_done': 'Je melding is naar de server van de gebruiker gestuurd. Hun moderators bekijken het.', 'fedi.delete_confirm': 'Deze reactie verwijderen?', 'fedi.mod_remove_confirm': 'Deze reactie uit je thread verwijderen? Hij komt niet terug, ook niet via thread-aanvulling.', 'fedi.mod_report_confirm': 'Deze reactie rapporteren bij de server van de auteur?', 'fedi.manage_title': 'Mijn fediverse-reacties', 'fedi.manage_empty': 'Je hebt nog geen reacties verstuurd.', 'fedi.goto_post': 'Naar de post', 'fedi.edit': 'Bewerken', 'fedi.save_edit': 'Opslaan', 'fedi.bm_label': 'Interacteer via mijn site', 'fedi.bm_help': 'Sleep deze knop naar je bladwijzerbalk. Klik ’m daarna op elke fediverse-post (Mastodon, een andere Klonkt…) om er via jouw site op te reageren, te liken of te boosten.', 'tl.title': 'Krant', 'tl.lead': 'Volg accounts in de fediverse en zie hun berichten hier.', 'tl.follow_btn': 'Volgen', 'tl.following': 'Je volgt', 'tl.unfollow': 'Ontvolgen', 'tl.autoboost': 'Uitgelicht', 'tl.autoboost_follow': 'uitlichten in cirkel', 'tl.autoboost_hint': 'Hun nieuwe posts verschijnen doorlopend in jouw Cirkel (lokaal, geen fediverse-boost).', 'tl.moved_title': 'Dit account is verhuisd', 'tl.moved_lead': 'Nieuwe berichten, volgen, liken en reageren doe je voortaan vanaf', 'tl.moved_hint': 'Lezen blijft hier gewoon werken, en reacties op je oude posts komen nog binnen. Wil je terug? Maak het verhuisadres leeg bij Uiterlijk.', 'tl.move_title': 'Je volglijst meenemen', 'tl.move_hint': 'Verhuis je naar een ander adres? Je volgers krijgen dat vanzelf te horen, maar wie JIJ volgt niet. Neem die lijst hiermee mee. Werkt ook van en naar Mastodon.', 'tl.move_export': 'Lijst downloaden (CSV)', 'tl.move_import_file': 'Kies je gedownloade CSV-bestand:', 'tl.move_import_lbl': 'Of plak de lijst hier:', 'tl.move_import': 'Iedereen volgen', 'tl.pending': 'in afwachting', 'tl.unboost': 'Boost intrekken', 'tl.feed': 'Berichten', 'tl.tab_feed': 'Krant', 'tl.tab_following': 'Volgend', 'tl.tab_replies': 'Reacties', 'tl.tab_followers': 'Volgers', 'tl.followers': 'Volgers', 'tl.followers_lead': 'Wie jou volgt in de fediverse, met de laatste geslaagde bezorging. Rood = nog nooit bezorgd of laatste poging mislukt — kandidaat om op te ruimen na een check.', 'tl.empty_followers': 'Nog geen volgers.', 'tl.last_delivery': 'Laatste bezorging', 'tl.never_delivered': 'Nog nooit bezorgd', 'tl.delivery_failed': 'laatste poging mislukt', 'tl.remove_follower': 'Verwijderen', 'tl.folreq_title': 'Volgverzoeken', 'tl.folreq_sub': 'Deze wachten op jouw ja of nee. Tot die tijd ziet de aanvrager niets van je posts.', 'tl.folreq_accept': 'Accepteer', 'tl.folreq_deny': 'Weiger', 'tl.approve_toggle': 'Volgers eerst goedkeuren', 'tl.approve_toggle_hint': 'Aan: volgverzoeken wachten hier op jouw ja. Uit: iedereen mag direct volgen.', 'tl.remove_confirm': 'Deze volger verwijderen? Een actief account moet je dan opnieuw volgen.', 'tl.tab_connect': 'Connect', 'tl.connect': 'Connect', 'tl.dir_following': 'jij volgt', 'tl.dir_follower': 'volgt jou', 'tl.dir_mutual': 'wederzijds', 'tl.connect_empty': 'Nog geen connecties. Volg iemand hierboven om te beginnen.', 'tl.unreachable': 'Niet bereikbaar', 'tl.unreachable_lead': 'Deze volgers konden we niet bereiken (nooit bezorgd of laatste poging mislukt). Ruim ze op na een handmatige check.', 'msg.tab': 'Berichten', 'msg.title': 'Berichten', 'msg.filter_all': 'Alles', 'msg.filter_msgs': 'Berichten', 'msg.filter_conv': 'Gesprekken', 'msg.filter_act': 'Activiteit', 'msg.filter_mod': 'Moderatie', 'msg.filter_sent': 'Verzonden', 'msg.search_ph': 'Zoeken in berichten…', 'msg.no_match': 'Niets gevonden.', 'msg.poll_done': 'Je peiling is afgelopen', 'msg.poll_total': '{n} stemmers', 'msg.you': 'Jij', 'msg.sent_reply': 'reageerde via de fediverse', 'msg.and_more': 'en {n} anderen', 'msg.liked_many': 'liketen je post', 'msg.boosted_many': 'boostten je post', 'msg.private': 'privé', 'msg.private_hint': 'Alleen aan jou gericht; staat niet op de publieke postpagina.', 'msg.new': 'Nieuw sinds je laatste bezoek', 'msg.empty': 'Nog geen berichten. Reacties, vermeldingen en activiteit verschijnen hier.', 'oauth.title': 'App toegang geven', 'oauth.wants_access': 'wil verbinding maken met je Klonkt-account.', 'oauth.post_as': 'Plaatsen als', 'oauth.scope_read': 'Je berichten, reacties en meldingen lezen', 'oauth.scope_write': 'Namens jou posten, reageren, liken en volgen', 'oauth.allow': 'Toestaan', 'oauth.deny': 'Weigeren', 'oauth.foot': 'Je kunt de toegang later intrekken. Geef alleen apps toegang die je vertrouwt.', 're.title': 'Reactie schrijven', 're.bold': 'Vet', 're.italic': 'Cursief', 're.link': 'Link invoegen', 're.list': 'Opsomming', 're.quote': 'Citaat', 're.lang': 'Taal van je reactie', 're.attach': 'Media toevoegen (afbeelding, audio, video)', 're.attach_err': 'Upload mislukt', 're.to': 'Aan:', 're.mention_del': 'Deze persoon niet meer adresseren', 'tl.empty_following': 'Je volgt nog niemand.', 'tl.empty': 'Nog niks — volg iemand om hun berichten hier te zien.', 'tl.view_original': 'Bekijk origineel →', 'tl.open_player': 'Open de speler', 'feed.load_more': 'Meer laden', 'tl.paste_ph': 'Plak een fediverse-post-URL', 'tl.paste_go': 'Openen', 'tl.boosted': 'boostte dit', 'tl.read_more': 'Meer lezen', 'tl.show_less': 'Minder', 'poll.vote': 'Stem', 'poll.votes': 'stemmen', 'poll.closed': 'gesloten', 'poll.open': 'open', 'poll.aria': 'Peiling', 'poll.voter_one': 'stemmer', 'poll.voter_many': 'stemmers', 'poll.closes': 'sluit op', 'poll.multiple': 'meerkeuze', 'poll.fedi_only': 'Stemmen kan vanuit de fediverse — volg deze site en stem in je eigen app.', 'poll.voted_title': 'Stem verstuurd', 'poll.voted_done': 'Je stem is verstuurd naar de poll. De uitslag werkt bij zodra de maker die doorstuurt.',
-    'comments.to_start': 'om de conversatie te starten.',
-    'comments.reply': 'Reageer', 'comments.delete': 'Verwijder', 'comments.cancel': 'Annuleren',
-    'comments.delete_confirm': 'Deze reactie verwijderen?',
-    'comments.reply_to': 'Antwoord aan {name}…',
-    'comments.add_as': 'Reageer als', 'comments.placeholder': 'Deel je gedachten…',
-    'comments.post': 'Plaats reactie', 'comments.login_to_comment': 'Log in om te reageren',
-    'comments.pending': 'Je reactie wacht op goedkeuring. Hij verschijnt zodra een beheerder hem goedkeurt.',
-    'related.title': 'Gerelateerde posts',
-    // Zoeken + like
-    'search.placeholder': 'Zoek posts en nummers…', 'search.button': 'Zoek',
-    'search.error': 'Kon die zoekopdracht niet uitvoeren. Probeer een eenvoudiger term.',
-    'search.results_one': '{n} resultaat voor “{q}”', 'search.results_other': '{n} resultaten voor “{q}”',
-    'search.section_tracks': 'Nummers', 'search.section_posts': 'Posts',
-    'search.empty': 'Niets gevonden.', 'search.in_post': 'in post →',
-    'search.section_events': 'Evenementen', 'search.section_pages': 'Pagina’s',
-    'search.page_agenda': 'Agenda', 'search.page_downloads': 'Downloads', 'search.page_links': 'Links', 'search.page_perskit': 'Perskit', 'search.page_archive': 'Archief',
-    'search.suggest_all': 'Alle resultaten →', 'search.suggest_empty': 'Geen resultaten', 'search.suggest_typing': 'Typ om te zoeken…',
-    'like.login_title': 'Log in om deze post te liken', 'like.fedi_title': 'Like deze post vanaf je eigen fediverse-account',
-    // === Beheer-sub-pagina's ===
-    'aset.title': 'Instellingen',
-    'aset.back_admin': 'Beheer',
-    'aset.mode': 'Modus',
-    'aset.mode_help': 'Bepaalt hoe deze installatie werkt. Wisselen is veilig: er wordt niets verwijderd — Solo verbergt alleen de multi-onderdelen en toont je primaire site.',
-    'aset.solo': 'Solo',
-    'aset.solo_title': 'één site (de jouwe).',
-    'aset.solo_desc': 'Geen gebruikers-directory, geen site-wissel.',
-    'aset.premium_badge': 'premium',
-    'aset.circle': 'Cirkels',
-    'aset.circle_title': 'solo + federatie.',
-    'aset.circle_desc': 'Eén eigen site die de publieke posts van andere Klonkt-sites toont. Asymmetrisch: jij bepaalt wie in jouw cirkel zit.',
-    'aset.save': 'Opslaan',
-    'aset.name': 'Naam',
-    'aset.name_ph': 'bijv. Studio Noord',
-    'aset.tagline': 'Tagline',
-    'aset.tagline_ph': 'bijv. Onafhankelijk muzieklabel',
-    'aset.intro': 'Intro',
-    'aset.intro_ph': 'Korte introtekst onder de titel.',
-    'aset.hero_image': 'Hero-afbeelding (URL)',
-    'aset.hero_image_hint': 'optioneel; achtergrond van de hero',
-    'aset.hero_upload': '…of upload een afbeelding',
-    'aset.hero_upload_hint': 'jpg/png/webp/gif, max 5 MB; vervangt de URL hierboven',
-    'aset.hero_overlay': 'Donkere overlay',
-    'aset.hero_overlay_hint': 'maakt de hero donkerder zodat de tekst leesbaar blijft',
-    'aset.preview_overlay': 'Voorbeeld (met overlay):',
-    'aset.hero_preview_alt': 'Hero-voorbeeld',
-    'aset.your_circle': 'Je cirkel',
-    'aset.your_circle_help': 'Beheer welke andere Klonkt-sites je in je cirkel toont, en of jouw site in cirkels van anderen mag verschijnen. Asymmetrisch: jij bepaalt wie je volgt.',
-    'aset.manage_circle': 'Beheer je cirkel',
-    'aset.premium': 'Premium (Patreon)',
-    'aset.premium_help_1': 'Ontgrendel de premium-modules (nieuwsbrief, downloads, statistieken, EPK, link-in-bio, agenda) met je',
-    'aset.premium_lifetime': '$16-lifetime',
-    'aset.premium_help_2': 'Patreon-steun. De app en alle updates blijven gratis.',
-    'aset.premium_active': 'Premium actief.',
-    'aset.lifetime_support': 'Lifetime-steun:',
-    'aset.patreon_disconnect': 'Patreon ontkoppelen',
-    'aset.patreon_no_lifetime': 'Patreon gekoppeld, maar nog geen $16 lifetime',
-    'aset.now': 'nu',
-    'aset.patreon_support_again': 'Steun de campagne en koppel opnieuw.',
-    'aset.patreon_reconnect': 'Opnieuw koppelen',
-    'aset.patreon_not_connected': 'Nog niet gekoppeld.',
-    'aset.patreon_connect': 'Koppel Patreon',
-    'aset.status_set': 'Status: ingesteld',
-    'aset.not_set_yet': 'Nog niet ingesteld.',
-    'aset.newsletter': 'Nieuwsbrief',
-    'aset.newsletter_help_1': 'Toon een',
-    'aset.newsletter_footer_field': 'aanmeldveld in de footer',
-    'aset.newsletter_help_2': 'van je site zodat bezoekers zich op elke pagina kunnen inschrijven. (De volledige aanmeldpagina blijft op',
-    'aset.newsletter_show_footer': 'Aanmeldveld in de footer tonen',
-    'aset.fediverse': 'Fediverse (ActivityPub)',
-    'aset.fediverse_help': 'Laat je site meedoen met de fediverse: mensen op Mastodon (of een andere Klonkt) kunnen je volgen, liken en reageren — en die reacties verschijnen onder je posts. Zet je dit uit, dan federeert je site niet en zijn er geen reacties (een rustige, op zichzelf staande blog).',
-    'aset.fediverse_toggle': 'Fediverse aan (volgen, liken, reageren)', 'aset.mode': 'Modus', 'aset.mode_help': 'Kies hoe je site werkt.', 'aset.mode_solo': 'Solo', 'aset.mode_solo_help': 'Een op zichzelf staande blog — geen fediverse, geen reacties. Rustig en privé.', 'aset.mode_cirkels': 'Cirkels', 'aset.mode_18plus': 'De fediverse is een open netwerk dat ook volwassen (18+) inhoud kan bevatten — je moet volwassen genoeg zijn om mee te doen.', 'aset.mode_18plus_confirm': 'Cirkels verbindt je site met de fediverse, een open netwerk met ook 18+-inhoud. Bevestig dat je volwassen genoeg bent om dit te activeren.', 'aset.mode_cirkels_help': 'Doe mee met de fediverse (ActivityPub): mensen op Mastodon of een andere Klonkt kunnen je volgen, liken en reageren, en je kunt een cirkel van sites volgen.',
-    'aset.email_smtp': 'E-mail (SMTP)',
-    'aset.smtp_help_1': 'Nodig om de',
-    'aset.smtp_help_newsletter': 'nieuwsbrief te versturen',
-    'aset.smtp_help_2': ',',
-    'aset.smtp_help_notify': 'show-notify',
-    'aset.smtp_help_3': '-mails te sturen en wachtwoord-reset per mail te laten werken. Vul de gegevens van je mailprovider in (bv. je hostingmail, Gmail-app-wachtwoord, Brevo, Mailgun…).',
-    'aset.via_env': 'via .env',
-    'aset.smtp_not_set': 'Nog niet ingesteld — versturen werkt nog niet.',
-    'aset.smtp_host': 'SMTP-host',
-    'aset.smtp_port': 'Poort',
-    'aset.smtp_port_hint': '587 (STARTTLS) of 465 (SSL)',
-    'aset.smtp_user': 'Gebruikersnaam',
-    'aset.smtp_pass': 'Wachtwoord',
-    'aset.smtp_pass_set_hint': 'ingesteld; leeg laten = ongewijzigd',
-    'aset.smtp_pass_ph_set': '•••••••• (ingesteld)',
-    'aset.smtp_pass_ph': 'app-wachtwoord',
-    'aset.smtp_from': 'Afzender',
-    'aset.smtp_from_hint': 'optioneel; standaard = gebruikersnaam',
-    'aset.smtp_from_ph': 'Jouw Naam <jij@jouwprovider.nl>',
-    'aset.smtp_save': 'SMTP opslaan',
-    'aset.clear': 'Wissen',
-    'aset.test_mail_to': 'Testmail sturen naar',
-    'aset.send_test_mail': 'Testmail sturen',
-    'asite.back_admin': 'Beheer',
-    'asite.title_new': 'Nieuwe site',
-    'asite.title_edit': 'Uiterlijk',
-    'asite.identity': 'Identiteit',
-    'asite.slug': 'Slug (URL)',
-    'asite.slug_fixed': '(vast)',
-    'asite.slug_placeholder': 'jouwslug',
-    'asite.field_title': 'Titel',
-    'asite.field_title_hint': '— getoond in de kop en als weergavenaam',
-    'asite.owner': 'Eigenaar',
-    'asite.owner_hint': '— wie deze Klonkt zelf mag beheren',
-    'asite.owner_god_suffix': ' (god)',
-    'asite.tagline': 'Tagline',
-    'asite.tagline_hint': '— korte oneliner',
-    'asite.bio': 'Bio / omschrijving',
-    'asite.bio_hint': '— getoond in de profielkop en gebruikt voor SEO',
-    'asite.profile_photo': 'Profielfoto',
-    'asite.photo_url_placeholder': '/media/avatars/foo.jpg of https://…',
-    'asite.photo_upload': '📷 Uploaden',
-    'asite.photo_clear': 'Verwijder',
-    'asite.language': 'Taal (ISO-code)',
-    'asite.profile_enabled': 'Profielkop tonen onder de navigatie',
-    'asite.appearance': 'Vormgeving',
-    'asite.accent_color': 'Accentkleur',
-    'asite.theme_default': 'Standaardthema voor nieuwe bezoekers',
-    'asite.theme_auto': 'Auto (volg device-voorkeur)',
-    'asite.theme_light': 'Licht',
-    'asite.theme_dark': 'Donker',
-    'asite.palette': 'Palet',
-    'asite.behavior': 'Gedrag',
-    'asite.is_public': 'Publieke site (uitvinken voor een besloten kring)',
-    'asite.robots_index': 'Zoekmachines mogen indexeren (sitemap.xml is verborgen indien uit)',
-    'asite.require_login_comment': 'Inloggen vereist om te reageren',
-    'asite.enable_audio': 'Audiospeler + embeds inschakelen', 'asite.approve_followers': 'Volgers eerst goedkeuren (volgverzoeken wachten op jouw ja op de Connect-pagina)',
-    'asite.links': 'Social / streaming-links',
-    'asite.links_hint': 'Getoond als merk-iconen op de profielkop. Voeg er zoveel toe als je wilt.',
-    'asite.aliases': 'Fediverse-aliassen',
-    'asite.move': 'Verhuizen (fediverse)',
-    'asite.move_hint': 'Kondig aan je volgers aan dat dit account ergens anders verder gaat. Het nieuwe profiel moet dit adres eerst als alias claimen; volgers verhuizen dan vanzelf mee. Een account met guardians kan nog niet verhuizen.',
-    'asite.move_confirm': 'Weet je het zeker? Je volgers krijgen te horen dat dit account is verhuisd.',
-    'asite.move_btn': 'Kondig verhuizing aan',
-    'asite.moved_to': 'Verhuisd naar',
-    'asite.aliases_hint': 'Eén per regel: je oude account als @naam@server of als actor-URL. Nodig om volgers van een oud account hierheen te verhuizen; de oude server controleert of dit profiel het oude claimt.',
-    'asite.link_add': '+ Link toevoegen',
-    'asite.feed_view': 'Feed-weergave',
-    'asite.feed_default': 'Standaardweergave voor de homepage',
-    'asite.feed_reader': 'Lezen (hele berichten, één per scherm)',
-    'asite.feed_grid': 'Grid (kaarten)',
-    'asite.feed_switch': 'Tijdlijn ↔ grid-schakelaar boven de feed tonen',
-    'asite.show_search': 'Zoekknop in de navigatie tonen',
-    'asite.show_archive': 'Archief-link in de navigatie tonen',
-    'asite.seo': 'SEO & social',
-    'asite.seo_pointer': 'Titel-sjabloon, canonical, deel-afbeelding, verificatie-metas en meer staan nu op een eigen pagina:',
-    'asite.seo_link': '🔎 SEO & vindbaarheid',
-    'asite.custom_legend': 'Eigen CSS & HTML',
-    'asite.optional': '(optioneel)',
-    'asite.custom_css': 'Eigen CSS (geïnjecteerd als &lt;style&gt; in &lt;head&gt;)',
-    'asite.custom_head': 'Eigen &lt;head&gt;-HTML (analytics, extra meta&rsquo;s)',
-    'asite.custom_foot': 'Eigen footer-HTML',
-    'asite.submit_create': 'Site aanmaken',
-    'asite.submit_save': 'Wijzigingen opslaan',
-    'aseo.back': 'Beheer',
-    'aseo.title': 'SEO & vindbaarheid',
-    'aseo.tagline_pre': 'Geavanceerde SEO van',
-    'aseo.tagline_post': '— hoe je site in zoekmachines en bij het delen op social media verschijnt.',
-    'aseo.index_legend': 'Indexeren',
-    'aseo.index_label': 'Zoekmachines mogen deze site indexeren',
-    'aseo.index_hint_pre': 'uit =',
-    'aseo.index_hint_post': '+ sitemap.xml verborgen',
-    'aseo.title_legend': 'Titel & omschrijving',
-    'aseo.title_template': 'Titel-sjabloon',
-    'aseo.title_template_hint_pre': 'gebruik',
-    'aseo.title_template_hint_and': 'en',
-    'aseo.default_desc': 'Standaard-omschrijving',
-    'aseo.default_desc_hint': 'meta description / og:description als een pagina er geen heeft',
-    'aseo.default_desc_ph': 'Korte omschrijving van je site (max ~160 tekens werkt het best)',
-    'aseo.canonical': 'Canonieke basis-URL',
-    'aseo.canonical_hint': 'de productie-HTTPS-URL, voorkomt dubbele-content-straf',
-    'aseo.author': 'Auteur',
-    'aseo.author_hint': 'meta author-tag',
-    'aseo.author_ph': 'Jouw naam',
-    'aseo.social_legend': 'Delen op social media',
-    'aseo.og_image': 'Standaard deel-afbeelding (URL)',
-    'aseo.og_image_hint': 'og:image / Twitter-card; ~1200×630px',
-    'aseo.og_theme': 'Deel-kaart licht of donker',
-    'aseo.og_theme_hint': 'de automatisch gemaakte deel-afbeelding',
-    'aseo.og_theme_auto': 'Automatisch (volgt site-thema)',
-    'aseo.og_theme_light': 'Licht',
-    'aseo.og_theme_dark': 'Donker',
-    'aseo.og_locale': 'Taal-locale',
-    'aseo.og_locale_hint_pre': 'og:locale, bv.',
-    'aseo.og_locale_hint_or': 'of',
-    'aseo.twitter': 'Twitter / X-handle',
-    'aseo.twitter_hint': 'met @',
-    'aseo.fb_app': 'Facebook App-ID',
-    'aseo.fb_app_hint': 'fb:app_id (optioneel)',
-    'aseo.publisher_legend': 'Uitgever (JSON-LD / rich results)',
-    'aseo.type': 'Type',
-    'aseo.type_person': 'Persoon',
-    'aseo.type_org': 'Organisatie / bedrijf',
-    'aseo.publisher_name': 'Naam',
-    'aseo.publisher_name_hint': 'valt terug op de site-titel',
-    'aseo.publisher_url': 'URL',
-    'aseo.publisher_logo': 'Logo (URL)',
-    'aseo.verify_legend': 'Zoekmachine-verificatie',
-    'aseo.verify_google': 'Google site-verificatie',
-    'aseo.verify_bing': 'Bing',
-    'aseo.verify_bing_hint': 'msvalidate.01',
-    'aseo.verify_pinterest': 'Pinterest',
-    'aseo.verify_pinterest_hint': 'p:domain_verify',
-    'aseo.verify_yandex': 'Yandex',
-    'aseo.save': 'SEO opslaan',
-    'aaud.title': 'Audio tracks',
-    'aaud.tagline_pre': 'MP3’s op site-niveau. Gebruik',
-    'aaud.tagline_post': 'in een post om een play-knop in te voegen.',
-    'aaud.upload': 'Upload',
-    'aaud.artist': 'Artiest',
-    'aaud.album': 'Album',
-    'aaud.applied_all': '(toegepast op alle bestanden)',
-    'aaud.optional': 'Optioneel',
-    'aaud.cover': 'Cover',
-    'aaud.cover_hint': '(optioneel, toegepast op alle bestanden — jpg/png/webp/gif, max 5 MB)',
-    'aaud.choose_cover': 'Kies cover',
-    'aaud.no_file': 'Geen bestand gekozen',
-    'aaud.drag_here': 'Sleep audio hierheen',
-    'aaud.or_click': 'of klik om bestanden te kiezen',
-    'aaud.start_upload': 'Start upload',
-    'aaud.clear_list': 'Wis lijst',
-    'aaud.tracks': 'Tracks',
-    'aaud.add_link_track': 'Track zonder audio',
-    'aaud.add_link_track_title': 'Een track zonder audiobestand — alleen titel + open-in links',
-    'aaud.no_tracks': 'Nog geen tracks. Upload er een hierboven.',
-    'aaud.untitled': '(zonder titel)',
-    'aaud.copy_click': 'Klik om te kopiëren',
-    'aaud.play': 'Afspelen',
-    'aaud.pause': 'Pauzeren',
-    'aaud.edit': 'Bewerken',
-    'aaud.delete': 'Verwijderen',
-    'aaud.delete_confirm': 'Track verwijderen?',
-    'aaud.dl_on': 'Download-voor-email staat AAN — klik om uit te zetten',
-    'aaud.dl_off': 'Download-voor-email staat uit — klik om aan te zetten',
-    'aaud.fedi_on': 'Op de fediverse gedeeld (speelt overal inline, bestand downloadbaar) — klik om uit te zetten',
-    'aaud.fedi_off': 'Niet op de fediverse gedeeld (alleen webspeler, bestand verborgen) — klik om te delen',
-    'aaud.embed_player': 'Embedbare speler',
-    'aseo.mb_legend': 'MusicBrainz Koppeling',
-    'aseo.mb_linked': 'Gekoppeld aan',
-    'aseo.mb_unlink': 'Ontkoppelen',
-    'aseo.mb_open': 'Bekijk op MusicBrainz',
-    'aseo.mb_pick': 'Dit ben ik',
-    'aseo.mb_none': 'Niets gevonden. Sta je er nog niet in? Dan kun je jezelf aanmelden op musicbrainz.org — dat kan alleen daar, niet vanuit Klonkt.',
-    'aseo.mb_busy': 'Zoeken…',
-    'aseo.mb_fail': 'MusicBrainz is even niet bereikbaar.',
-    'aseo.mb_hint': 'Hier koppel je je MusicBrainz artiest id aan je domein, met terug-weg validatie van je "social networking" profiel pagina.',
-    'aseo.mb_search_label': 'Zoek op je naam',
-    'aseo.mb_search_hint': 'je artiestennaam of je MusicBrainz id als je die kent',
-    'aseo.mb_placeholder': 'Ozzy Osbourne',
-    'aseo.mb_search': 'Opzoeken',
-    'aseo.mb_verified': 'Wederzijds: de MusicBrainz-pagina wijst terug naar dit domein.',
-    'aseo.mb_unverified': 'Nog eenzijdig. Zet dit domein op je MusicBrainz-pagina onder "social networking", dan is de koppeling van twee kanten bevestigd.',
-    'aseo.mb_checking': 'Terug-weg controleren…',
-    'aaud.embed_hint': 'Plak deze code op je eigen website/blog om je muziek met deze speler in te sluiten:',
-    'aaud.preview_player': 'Speler-voorbeeld openen',
-    'aaud.st_queued': 'Wachten',
-    'aaud.st_uploading': 'Uploaden…',
-    'aaud.st_transcoding': 'Converteren…',
-    'aaud.st_done': 'Klaar',
-    'aaud.st_error': 'Fout',
-    'aaud.err_unexpected': 'Onverwacht serverantwoord',
-    'aaud.failed': 'Mislukt',
-    'aaud.copied': 'gekopieerd',
-    'aaud.new_track': 'Nieuwe track',
-    'aaud.create_failed': 'Track aanmaken mislukt',
-    'aaud.editor_not_loaded': 'Track editor niet geladen',
-    'aaud.change_failed': 'Kon niet wijzigen',
-    'astat.title': 'Statistieken',
-    'astat.intro': 'Cookievrij gemeten — geen tracking-cookies, geen toestemmingsbanner. Bezoekers worden per dag geteld via een dagelijks roterende, anonieme hash (IP/browser worden niet bewaard). Je eigen beheerder-bezoeken en bekende bots/crawlers tellen niet mee.',
-    'astat.your_ip': 'Jouw IP', 'astat.ip_counted': 'wordt meegeteld.', 'astat.ip_not_counted': 'wordt NIET meegeteld.', 'astat.ip_exclude': 'Tel mijn bezoeken niet mee', 'astat.ip_count': 'Wel meetellen',
-    'astat.visitor_days': 'Bezoeker-dagen ({n}d)',
-    'astat.pageviews_days': 'Weergaven ({n}d)',
-    'astat.plays_total': 'Plays (totaal)',
-    'astat.postviews_total': 'Post-weergaven (totaal)',
-    'astat.alltime_pre': 'All-time:',
-    'astat.alltime_mid': 'weergaven',
-    'astat.alltime_post': 'bezoeker-dagen.',
-    'astat.help_summary': 'Wat betekenen deze cijfers?',
-    'astat.help_vd_term': 'Bezoeker-dagen',
-    'astat.help_vd_a': 'het aantal unieke bezoekers',
-    'astat.help_vd_em': 'per dag, bij elkaar opgeteld',
-    'astat.help_vd_b': '. Eén persoon die 5 dagen langskomt = 5 bezoeker-dagen. Cookieloos kan er niet over dagen heen geteld worden, dus dit is géén aantal unieke personen — het echte aantal mensen ligt (vaak fors) lager.',
-    'astat.help_pv_term': 'Weergaven',
-    'astat.help_pv': 'hoe vaak de home/feed of een post geladen is (ook bij klikken binnen de site). Andere pagina’s (agenda, downloads, links) tellen hier niet in mee.',
-    'astat.help_plays_term': 'Plays',
-    'astat.help_plays': 'totaal aantal keren dat een track is gestart.',
-    'astat.help_postviews_term': 'Post-weergaven',
-    'astat.help_postviews': 'totaal over alle posts samen.',
-    'astat.help_footer': 'Beheerder-bezoeken en bekende bots/crawlers worden overgeslagen. Het ruwe IP wordt nooit bewaard. Goed voor trends; neem absolute aantallen met een korrel zout.',
-    'astat.period': 'Periode:',
-    'astat.last_n_days': 'Laatste {n} dagen',
-    'astat.lg_visitor_days': 'Bezoeker-dagen',
-    'astat.lg_pageviews': 'Weergaven',
-    'astat.bar_title': '{day} — {pv} weergaven, {vd} bezoeker-dagen',
-    'astat.top_posts': 'Populairste posts',
-    'astat.no_views': 'Nog geen weergaven.',
-    'astat.most_played': 'Meest geluisterd',
-    'astat.no_plays': 'Nog geen plays.',
-    'astat.sources': 'Bronnen (waar bezoekers vandaan komen)',
-    'astat.linkbio_clicks': 'Link-in-bio klikken',
-    'apl.title': 'Playlists',
-    'apl.tagline_pre': 'Canonieke playlists. Bewerk een playlist hier en de wijzigingen werken door in álle posts die hem gebruiken via',
-    'apl.tagline_post': '.',
-    'apl.new_playlist': 'Nieuwe playlist',
-    'apl.none': 'Nog geen playlists.',
-    'apl.none_sub': 'Maak er een via de knop hierboven, of via de 📃 knop in de post-editor.',
-    'apl.pill_playlist': 'playlist',
-    'apl.pill_album': 'album', 'apl.pill_mixtape': 'mixtape',
-    'apl.track': 'track',
-    'apl.tracks': 'tracks',
-    'apl.copy_click': 'Klik om te kopiëren',
-    'apl.edit': 'Bewerken',
-    'apl.delete': 'Verwijderen',
-    'apl.copied': 'gekopieerd',
-    'apl.delete_confirm': 'Playlist "{title}" verwijderen? Posts die deze playlist embedden tonen vanaf nu een placeholder.',
-    'apl.delete_failed': 'Verwijderen mislukt',
-    'ausr.back': 'Beheer',
-    'ausr.title': 'Gebruikers',
-    'ausr.tagline_a': 'Beheer gebruikers, rollen, en verwijderingen. Rol',
-    'ausr.tagline_b': '= alles bekijken (incl. Beheer), niets wijzigen — handig voor demo\'s.',
-    'ausr.empty': 'Geen gebruikers.',
-    'ausr.you': 'jij',
-    'ausr.t_sites': 'Sites',
-    'ausr.t_posts': 'Posts',
-    'ausr.t_joined': 'Geregistreerd op',
-    'ausr.l_sites': 'sites',
-    'ausr.l_posts': 'posts',
-    'ausr.l_joined': 'lid sinds',
-    'ausr.new_klonkt': 'Nieuwe Klonkt voor deze gebruiker',
-    'ausr.new_klonkt_for': 'Nieuwe Klonkt voor {name}',
-    'ausr.role_label': 'Rol',
-    'ausr.role_kijker': 'kijker',
-    'ausr.role_member': 'member',
-    'ausr.role_admin': 'admin',
-    'ausr.role_god': 'god',
-    'ausr.delete': 'Verwijderen',
-    'ausr.del_warn': 'Dit verwijdert ook hun site + {n} post(s).',
-    'ausr.del_confirm': 'Gebruiker {name} verwijderen?',
-    'ausr.del_undo': 'Dit kan niet ongedaan worden.',
-    'asit2.back': 'Beheer',
-    'asit2.title': 'Sites',
-    'asit2.tagline': 'Beheer alle sites op deze installatie.',
-    'asit2.new_site': 'Nieuwe site',
-    'asit2.empty': 'Nog geen sites.',
-    'asit2.empty_sub': 'Maak er een via de knop hierboven.',
-    'asit2.pill_primary': 'primair',
-    'asit2.pill_primary_title': 'Hoofd-/labelsite van deze installatie',
-    'asit2.pill_public': 'public',
-    'asit2.pill_public_title': 'Publiek zichtbaar',
-    'asit2.pill_private': 'private',
-    'asit2.pill_private_title': 'Niet publiek',
-    'asit2.pill_noindex': 'noindex',
-    'asit2.pill_noindex_title': 'Niet geïndexeerd door zoekmachines',
-    'asit2.by': 'door',
-    'asit2.t_posts': 'Aantal posts',
-    'asit2.l_posts': 'posts',
-    'asit2.t_created': 'Aangemaakt op',
-    'asit2.l_created': 'aangemaakt',
-    'asit2.make_primary': 'Maak primair',
-    'asit2.make_primary_title': 'Maak primaire/hoofd-site',
-    'asit2.make_primary_confirm': 'Deze site instellen als de primaire/hoofd-site?',
-    'asit2.edit': 'Bewerken',
-    'asit2.delete': 'Verwijderen',
-    'asit2.delete_confirm': 'Site verwijderen? Lukt alleen als er geen posts zijn.',
-    'acom.back': 'Beheer',
-    'acom.title': 'Reactie-moderatie',
-    'acom.mode_for_site': 'Modus voor deze site:',
-    'acom.mode_trust_a': 'reacties worden automatisch goedgekeurd. Zet om naar',
-    'acom.mode_moderate_word': 'modereren',
-    'acom.site_settings': 'site-instellingen',
-    'acom.mode_trust_b': 'om ze in de wachtrij te zetten.',
-    'acom.mode_moderate_hint': 'nieuwe reacties moeten worden goedgekeurd voordat ze bij posts verschijnen.',
-    'acom.pending': 'In afwachting ({n})',
-    'acom.nothing_waiting': 'Niets in de wachtrij.',
-    'acom.reply': 'antwoord',
-    'acom.on': 'op',
-    'acom.approve': 'Goedkeuren',
-    'acom.reject': 'Afwijzen',
-    'acom.recent': 'Recente beslissingen',
-    'acom.nothing_yet': 'Nog niets.',
-    'acir.title': 'Cirkels',
-    'acir.back_settings': 'Instellingen',
-    'acir.circles': 'Cirkels',
-    'acir.settings': 'Instellingen',
-    'acir.mode_off_1': 'De modus staat niet op',
-    'acir.mode_off_2': '. Zet \'m aan bij',
-    'acir.mode_off_3': 'om je cirkel-feed te tonen op',
-    'acir.mode_off_4': '. Je kunt hieronder al wel bronnen klaarzetten.',
-    'acir.visibility_title': 'Mijn zichtbaarheid',
-    'acir.all_public': 'al-publieke',
-    'acir.visibility_help_1': 'Doe je mee aan cirkels? Dit maakt je',
-    'acir.visibility_help_2': 'posts ophaalbaar voor andere Klonkt-sites via een ondertekende feed',
-    'acir.visibility_help_3': 'Het is een deelname-keuze, geen privacy-slot: je posts blijven sowieso openbaar op je site, ook als dit uit staat. Wil je iets afschermen, maak die post dan niet-publiek.',
-    'acir.show_in_circles': 'Toon mijn site in cirkels van anderen',
-    'acir.save': 'Opslaan',
-    'acir.add_title': 'Klonkt-site toevoegen',
-    'acir.add_help': 'Plak de basis-URL van een andere Klonkt-site. Asymmetrisch: jij toont hen, los van of zij jou tonen.',
-    'acir.url': 'URL',
-    'acir.label': 'Label',
-    'acir.optional': 'optioneel',
-    'acir.name_auto': 'De naam wordt automatisch van de site overgenomen — alleen de URL is nodig.',
-    'acir.label_ph': 'bijv. Joost Klein',
-    'acir.add': 'Toevoegen',
-    'acir.in_circle': 'In mijn cirkel ({n})',
-    'acir.no_sources': 'Nog geen bronnen. Voeg er hierboven een toe.',
-    'acir.sync_all': 'Alles nu synchroniseren',
-    'acir.st_active': 'actief',
-    'acir.posts': 'posts',
-    'acir.last': 'laatst',
-    'acir.st_mismatch': 'versie-mismatch',
-    'acir.mismatch_reason': 'Deze site draait een andere Klonkt-protocolversie — bijwerken nodig om te federeren.',
-    'acir.st_error': 'fout',
-    'acir.st_paused': 'gepauzeerd',
-    'acir.refresh': 'Verversen',
-    'acir.remove': 'Verwijder',
-    'acir.remove_confirm': 'Verwijderen uit je cirkel?',
-    'ashow.back_admin': 'Beheer',
-    'ashow.title': 'Agenda',
-    'ashow.show_toggle': 'Agenda tonen op de site',
-    'ashow.show_toggle_hint': '(Agenda-knop in de balk + de agendapagina)',
-    'ashow.save': 'Opslaan',
-    'ashow.off': 'uit',
-    'ashow.off_notice_1': 'De agenda staat nu',
-    'ashow.off_notice_2': '— bezoekers zien geen Agenda-knop en de agendapagina is niet bereikbaar. Zet \'m aan om je evenementen te tonen.',
-    'ashow.subscribers': 'abonnee(s) voor evenement-aankondigingen.',
-    'ashow.smtp_warn': '⚠ SMTP niet ingesteld — evenementen worden opgeslagen, maar notify-mails kunnen pas verstuurd worden zodra je SMTP invult.',
-    'ashow.f_date': 'Datum',
-    'ashow.f_time': 'Tijd (optioneel)',
-    'ashow.f_city': 'Plaats',
-    'ashow.f_country': 'Land (optioneel)',
-    'ashow.f_venue': 'Locatie/zaal (optioneel)',
-    'ashow.f_ticket': 'Ticket-URL (optioneel)',
-    'ashow.f_notes': 'Notitie (optioneel)',
-    'ashow.f_notes_ph': 'Support: ...',
-    'ashow.notify_label': 'Abonnees per e-mail op de hoogte brengen',
-    'ashow.smtp_required': '(SMTP vereist)',
-    'ashow.add_event': '+ Evenement toevoegen',
-    'ashow.del_confirm': 'Evenement verwijderen??',
-    'ashow.empty': 'Nog geen evenementen.',
-    'anews.title': 'Nieuwsbrief',
-    'anews.confirmed': 'bevestigd',
-    'anews.pending': 'in afwachting',
-    'anews.unsub': 'uitgeschreven',
-    'anews.smtp_warn_1': '⚠ SMTP is nog niet ingesteld. Aanmeldingen worden wél verzameld, maar versturen kan pas als je SMTP-gegevens invult',
-    'anews.smtp_warn_2': 'in',
-    'anews.share': 'Aanmeldlink om te delen:',
-    'anews.subject': 'Onderwerp',
-    'anews.subject_ph': 'Nieuwe single uit!',
-    'anews.body': 'Bericht',
-    'anews.body_ph': 'Schrijf je update…',
-    'anews.send_confirm': 'Nieuwsbrief versturen naar {n} bevestigde abonnee(s)?',
-    'anews.send_btn': 'Versturen naar {n} abonnee(s)',
-    'anews.sent_heading': 'Verstuurd',
-    'anews.recipients': 'ontvanger(s)',
-    'aupd.title': 'Updates',
-    'aupd.changes_heading': 'Laatste wijzigingen',
-    'aupd.back_admin': 'Beheer',
-    'aupd.version_heading': 'Versie van deze Klonkt',
-    'aupd.app_version': 'App-versie',
-    'aupd.current': 'Huidig',
-    'aupd.current_unknown': 'onbekend (nog niet via de update-knop bijgewerkt)',
-    'aupd.latest': 'Nieuwste',
-    'aupd.latest_failed': 'kon de nieuwste versie niet ophalen',
-    'aupd.no_source': 'Geen update-bron bereikbaar',
-    'aupd.uptodate': 'Up-to-date',
-    'aupd.update_available': 'Update beschikbaar',
-    'aupd.behind_one': '{n} commit achter',
-    'aupd.behind_many': '{n} commits achter',
-    'aupd.run_confirm': 'De site wordt naar de nieuwste versie gebracht en herstart kort. Doorgaan?',
-    'aupd.redeploy': 'Opnieuw uitrollen',
-    'aupd.update_now': 'Nu bijwerken',
-    'aupd.help': 'Bijwerken haalt de nieuwste code op en herstart deze site kort (~10s). Doe dit rustig — er gaat niets verloren (je posts, instellingen en cirkel blijven staan).',
-    'aupd.manual_hint': 'Werk bij vanaf GitHub door dit op je server uit te voeren:',
-    'aepk.title': 'Perskit bewerken',
-    'aepk.saved': 'Perskit opgeslagen',
-    'aepk.back_admin': 'Beheer',
-    'aepk.view_epk': 'Bekijk perskit',
-    'aepk.text_heading': 'Tekst',
-    'aepk.text_help': 'De perskit (/pers) toont je sitenaam + foto, deze bio en contact, plus automatisch je meest beluisterde nummers en recente posts. Laat de bio leeg om de site-tagline te gebruiken; laat contact leeg om niets te tonen (je login-mail wordt nooit automatisch getoond).',
-    'aepk.bio_label': 'Pers-bio',
-    'aepk.bio_ph': 'Korte beschrijving van jou/het project voor pers & boekers.',
-    'aepk.contact_label': 'Pers-contact',
-    'aepk.contact_ph': 'bv. pers@jouwdomein.nl of een boekingslink',
-    'aepk.tracks_label': 'Nummers op de perskit',
-    'aepk.tracks_hint': '(kies er max {n}; laat leeg voor automatisch de top {n} meest beluisterd)',
-    'aepk.no_tracks': 'Nog geen nummers — voeg eerst audio toe in Beheer → Audio.',
-    'aepk.untitled': '(zonder titel)',
-    'aepk.save': 'Opslaan',
-    'ahelp.back': 'Beheer',
-    'ahelp.title': 'Handleiding',
-    'ahelp.intro': 'Uitleg van alle functies. Typ hieronder om te zoeken op een onderwerp of instructie.',
-    'ahelp.search_placeholder': 'Zoek… (bv. \'agenda\', \'foto\', \'cirkel\', \'downloads\')',
-    'ahelp.search_aria': 'Zoek in de handleiding',
-    'ahelp.premium': 'premium',
-    'ahelp.empty': 'Geen onderwerpen gevonden voor je zoekopdracht.',
-    'ahelp.s_newpost_h': 'Nieuwe post schrijven',
-    'ahelp.s_newpost_b': 'Beheer → <strong>Nieuwe post</strong>. Bovenaan kies je het <strong>type</strong> (Post · Foto · Video · Audio) — dat bepaalt de invoer eronder. Geef een titel en schrijf je inhoud. Onderaan kies je de status: <em>concept</em> (niet zichtbaar) of <em>gepubliceerd</em>. Concepten staan bovenaan in je Beheer-overzicht zodat je ze terugvindt.',
-    'ahelp.s_excerpt_h': 'Samenvatting & Cirkel Preview',
-    'ahelp.s_excerpt_b': 'Het veld <strong>Samenvatting & Cirkel Preview</strong> (de excerpt) is de korte previewtekst onder een post in lijsten, én de samenvatting die andere sites tonen als ze je post via een <strong>Cirkel</strong> overnemen. Laat je \'m leeg, dan wordt automatisch het begin van de post gebruikt.',
-    'ahelp.s_pin_h': 'Post pinnen / volgorde',
-    'ahelp.s_pin_b': 'In de post-editor kun je een post <strong>pinnen</strong> met een rang (1 = bovenaan). Gepinde posts staan vooraan in de tijdlijn/grid, op volgorde van hun rang. Rang leeg of 0 = niet gepind.',
-    'ahelp.s_schedule_h': 'Publiceren plannen & alleen voor vrienden',
-    'ahelp.s_schedule_b': 'In de editor kun je een <strong>publicatiedatum</strong> in de toekomst zetten; de post verschijnt dan automatisch op dat moment. Met <strong>Alleen voor vrienden</strong> zien niet-ingelogde bezoekers alleen een teaser + login-uitnodiging; ingelogde vrienden zien alles.',
-    'ahelp.s_images_h': 'Afbeeldingen in posts',
-    'ahelp.s_images_b': 'Afbeeldingen in de tekst en de cover-afbeelding worden altijd <strong>volledig</strong> getoond op de volledige breedte (niet bijgesneden), met hun natuurlijke hoogte.',
-    'ahelp.s_audio_h': 'Audio & nummers toevoegen',
-    'ahelp.s_audio_b': 'Beheer → <strong>Audio</strong>. Upload een bestand of voeg een <em>link-only</em> nummer toe (zonder upload, alleen "open in"-links). Per nummer vul je titel, artiest, cover, en optioneel album/positie in. In een post toon je een nummer met de shortcode <code>[[track:id]]</code>, een album met <code>[[album:Naam]]</code>, een playlist met <code>[[playlist:id]]</code>. <strong>Sneller:</strong> kies in een post bovenaan type <em>Audio</em> en sleep het bestand er direct in — het wordt omgezet en meteen in de post gezet.',
-    'ahelp.s_credit_h': 'Credit, licentie & "open in"',
-    'ahelp.s_credit_b': 'Per nummer kun je een <strong>eigenaar/credit</strong> (met © -knop) en een <strong>licentie</strong> instellen — die worden ook in de mp3-metadata geschreven. Met de <strong>open-in</strong>-velden (Spotify / YouTube / SoundCloud) verschijnen knoppen om het nummer op die platforms te openen.',
-    'ahelp.s_downloads_h': 'Downloads',
-    'ahelp.s_downloads_b': 'Markeer een nummer als <strong>downloadbaar</strong> in Beheer → Audio (⬇-knop). Bezoekers vinden ze op de <strong>/downloads</strong>-pagina en laten hun e-mail achter om het bestand te krijgen (komt op je mailinglijst). Wil je downloads prominent in de feed? Maak een gewone post met slug <code>downloads</code> en pin \'m.',
-    'ahelp.s_albums_h': 'Albums & playlists',
-    'ahelp.s_albums_b': 'Geef nummers hetzelfde <strong>album</strong> + een <strong>positie</strong> om een album te vormen. Playlists maak je in Beheer → <strong>Playlists</strong>. Beide toon je in een post met <code>[[album:Naam]]</code> of <code>[[playlist:id]]</code>.',
-    'ahelp.s_agenda_h': 'Agenda / evenementen',
-    'ahelp.s_agenda_b': 'Beheer → <strong>Agenda</strong>. Zet bovenaan <strong>"Agenda tonen op de site"</strong> aan — dan verschijnt de Agenda-knop in de balk en is de agendapagina bereikbaar. Voeg evenementen toe (datum, plaats, locatie, tickets). Bezoekers kunnen zich (los van de nieuwsbrief) aanmelden voor een seintje bij een nieuw evenement.',
-    'ahelp.s_presskit_h': 'Perskit',
-    'ahelp.s_presskit_b': 'Een deelbare perspagina op <strong>/pers</strong>. Bewerk \'m via de <strong>✎ Bewerken</strong>-knop op die pagina zelf (alleen jij ziet die). Stel een korte pers-bio + contact in, en kies <strong>tot 5 nummers</strong> die getoond worden (of laat leeg = automatisch de top-5 meest beluisterd).',
-    'ahelp.s_circles_h': 'Cirkels (federatie)',
-    'ahelp.s_circles_b': 'Een <strong>Cirkel</strong> is je eigen samengestelde feed. Open <strong>Fediverse → Volgend</strong>, volg accounts en <strong>licht ze uit</strong> (✨). Uitgelichte accounts én posts die je <strong>boost</strong> (🔁) verschijnen in je <strong>/cirkel</strong>-feed — een geboooste post krijgt een Boost-badge. Boost je een post van iemand die je niet volgt, dan komt die er ook in. Dit is lokaal: er gaat niets automatisch de fediverse op — naar je eigen volgers boosten doe je bewust per post.',
-    'ahelp.s_stats_h': 'Statistieken',
-    'ahelp.s_stats_b': 'Beheer → <strong>Statistieken</strong>. Cookievrij gemeten. <strong>Bezoeker-dagen</strong> = unieke bezoekers per dag, opgeteld (géén aantal personen). <strong>Weergaven</strong> = home/feed- en post-loads. Beheerder-bezoeken en bots tellen niet mee. Goed voor trends; absolute aantallen met een korrel zout.',
-    'ahelp.s_newsletter_h': 'Nieuwsbrief',
-    'ahelp.s_newsletter_b': 'Beheer → <strong>Nieuwsbrief</strong>: stel een bericht op en stuur het naar je bevestigde abonnees. Bezoekers melden zich aan via de footer of <strong>/nieuwsbrief</strong>. Versturen vereist dat e-mail (SMTP) is ingesteld.',
-    'ahelp.s_linkbio_h': 'Link-in-bio',
-    'ahelp.s_linkbio_b': 'Een Linktree-achtige pagina op <strong>/links</strong> met je profiel-links. De kliks per link zie je terug in Statistieken.',
-    'ahelp.s_embed_h': 'Embedbare speler',
-    'ahelp.s_embed_b': 'Beheer → Audio toont een kopieerbare <code>&lt;iframe&gt;</code>-code (<strong>/embed</strong>) waarmee je je speler op een andere website kunt insluiten.',
-    'ahelp.s_appearance_h': 'Uiterlijk (thema, foto, accent)',
-    'ahelp.s_appearance_b': 'Beheer → <strong>Uiterlijk</strong>: stel je sitenaam, tagline, profielfoto, accentkleur en kleurpalet in, en de standaard feed-weergave (Tijdlijn of Grid).',
-    'ahelp.s_tenancy_h': 'Solo- / Cirkel-modus',
-    'ahelp.s_tenancy_b': 'Bovenaan <strong>Beheer → Instellingen</strong> kies je de modus. <em>Solo</em> = een op zichzelf staande blog: geen fediverse, geen reacties. <em>Cirkels</em> = je site doet mee aan de fediverse (ActivityPub): mensen kunnen je volgen en reageren, en je krijgt de Fediverse-sectie + je Cirkel-feed. Wisselen kan veilig — er wordt niets verwijderd.',
-    'ahelp.s_fedi_h': 'Fediverse (ActivityPub)',
-    'ahelp.s_fedi_b': 'In de modus <strong>Cirkels</strong> doet je site mee aan de fediverse (Mastodon e.d.). Open de <strong>Fediverse</strong>-sectie via de wereldbol in de menubalk (of de bel voor meldingen). Vijf tabs: <strong>News</strong> (de berichten van wie je volgt — hier ⭐ liken en 🔁 boosten, nog eens klikken = ongedaan), <strong>Volgend</strong> (accounts volgen via een @handle of profiel-URL, en uitlichten ✨ voor je Cirkel), <strong>Reacties</strong> (je verzonden reacties + de sleep-naar-bladwijzerbalk <em>interactie-bookmarklet</em> om vanaf elke fediverse-post te reageren), <strong>Meldingen</strong> (nieuwe volgers, likes, boosts en reacties op jouw posts) en <strong>Blokkeren</strong> (een account of heel domein blokkeren). Onder elke post zie je bij "Vanuit de fediverse" de inkomende reacties, likes en boosts; als eigenaar kun je daar direct reageren, liken of boosten. Bezoekers gebruiken de knop "Interact via the fediverse" om vanaf hun eigen account te reageren. Klik je profielfoto voor een samenvatting van je profiel; bezoekers vinden daar ook een "Volg via de fediverse"-knop. Je posts worden automatisch aan je volgers bezorgd.',
-    'ahelp.s_premium_h': 'Premium / Patreon',
-    'ahelp.s_premium_b': 'Premium-functies (Statistieken, Agenda, Downloads, Perskit, Nieuwsbrief, Link-in-bio, Embed) ontgrendel je in Beheer → Instellingen door Patreon te koppelen ($16 lifetime). Updates en de kernapp blijven altijd gratis.',
-    'ahelp.s_password_h': 'Wachtwoord vergeten / resetten',
-    'ahelp.s_password_b': 'Resetten gaat via de <strong>command-line scripts</strong> op de server. Draai in de projectmap <code>npm run reset-admin</code>: zonder argument reset dat de god-user en print het nieuwe wachtwoord. Een specifieke gebruiker: <code>npm run reset-admin -- &lt;gebruiker|e-mail&gt;</code>. Zelf een wachtwoord kiezen (minstens 8 tekens): <code>npm run reset-admin -- &lt;gebruiker|e-mail&gt; &lt;wachtwoord&gt;</code>. Log daarna in via <strong>/auth/login</strong>. De resetlink-per-mail op <strong>/auth/reset-request</strong> werkt alleen als e-mail (SMTP) is ingesteld; de command-line werkt altijd.',
-    'ahelp.s_updates_h': 'Updates',
-    'ahelp.s_updates_b': 'Beheer → <strong>Updates</strong> (alleen god) toont de huidige versie en of er een nieuwere is. Met "Nu bijwerken" haal je de laatste versie binnen.',
-    // === Publieke + overige pagina's ===
-    'pedit.title_new': 'Nieuwe post',
-    'pedit.title_edit': 'Post bewerken',
-    'pedit.f_title': 'Titel',
-    'pedit.f_slug': 'Slug (URL)',
-    'pedit.slug_placeholder': 'auto van titel als leeg',
-    'pedit.f_tags': 'Tags',
-    'pedit.tags_hint': '(komma-gescheiden)',
-    'pedit.f_excerpt': 'Samenvatting & Cirkel Preview',
-    'pedit.excerpt_hint': 'Wordt ook gebruikt als samenvatting in <strong>Cirkels</strong> (andere sites die je post tonen). Leeg laten = begin van de post.',
-    'pedit.s_cover': 'Cover',
-    'pedit.f_cover_url': 'Cover URL',
-    'pedit.cover_url_placeholder': '/media/…  of https://…  (of upload met de knop)',
-    'pedit.f_cover_alt': 'Alt-tekst (beschrijving)',
-    'pedit.cover_alt_placeholder': 'Beschrijf de afbeelding voor schermlezers',
-    'pedit.f_language': 'Taal',
-    'pedit.language_hint': 'voor het fediverse-taalfilter',
-    'pedit.cover_upload_btn': 'Upload nieuwe cover',
-    'pedit.s_content': 'Content',
-    'pedit.content_hint': 'Sleep een afbeelding om in te voegen · selecteer tekst om op te maken',
-    'pedit.tb_done': 'Klaar',
-    'pedit.tb_done_title': 'Klaar met bewerken',
-    'pedit.tb_bold': 'Vet',
-    'pedit.tb_bold_title': 'Vet (Ctrl+B)',
-    'pedit.tb_italic': 'Cursief',
-    'pedit.tb_italic_title': 'Cursief (Ctrl+I)',
-    'pedit.tb_underline': 'Onderstreept',
-    'pedit.tb_h2': 'Kop',
-    'pedit.tb_h3': 'Subkop',
-    'pedit.tb_p': 'Paragraaf',
-    'pedit.tb_ul': 'Lijst',
-    'pedit.tb_ol': 'Genummerde lijst',
-    'pedit.tb_quote': 'Quote',
-    'pedit.tb_link': 'Link',
-    'pedit.tb_link_title': 'Link (Ctrl+K)',
-    'pedit.tb_code': 'Code',
-    'pedit.tb_code_title': 'Code (inline)',
-    'pedit.tb_image': 'Afbeelding',
-    'pedit.tb_image_title': 'Afbeelding invoegen',
-    'pedit.tb_track': 'Track',
-    'pedit.tb_track_title': 'Track invoegen',
-    'pedit.tb_playlist': 'Playlist',
-    'pedit.tb_playlist_title': 'Playlist invoegen',
-    'pedit.tb_embed': 'Media embedden',
-    'pedit.tb_embed_title': 'Embed (YouTube, Spotify, SoundCloud, Vimeo…)',
-    'pedit.tb_clear': 'Wis opmaak',
-    'pedit.tb_fullscreen': 'Volledig scherm',
-    'pedit.editor_aria': 'Content',
-    'pedit.editor_placeholder': 'Begin met schrijven…',
-    'pedit.tap_to_edit': 'Tik om te bewerken',
-    'pedit.tap_to_write': 'Tik om te schrijven…',
-    'pedit.chars': 'tekens',
-    'pedit.s_publication': 'Publicatie',
-    'pedit.f_status': 'Status',
-    'pedit.status_published': 'Gepubliceerd',
-    'pedit.status_draft': 'Concept',
-    'pedit.status_archived': 'Gearchiveerd',
-    'pedit.f_type': 'Type',
-    'pedit.type_post': 'Post',
-    'pedit.type_foto': 'Foto',
-    'pedit.type_video': 'Video',
-    'pedit.type_audio': 'Audio',
-    'pedit.type_album': 'Album',
-    'pedit.type_playlist': 'Playlist', 'pedit.type_mixtape': 'Mixtape',
-    'pedit.s_type': 'Wat voor post?',
-    'pedit.audio_up_drop': 'Sleep audio hierheen of klik om te kiezen',
-    'pedit.audio_up_hint': 'mp3, m4a, ogg, flac, wav — wordt automatisch omgezet en meteen in je post gezet. Details pas je later aan via de track.',
-    'pedit.audio_up_busy': 'Uploaden…',
-    'pedit.audio_up_done': 'In je post gezet',
-    'pedit.audio_up_fail': 'Mislukt',
-    'pedit.video_up_title': 'Video toevoegen',
-    'pedit.video_up_ph': 'Plak een video-URL (YouTube, Vimeo…)',
-    'pedit.video_up_btn': 'Invoegen',
-    'pedit.foto_up_hint': 'Zet je foto als cover hieronder, of voeg foto’s toe in de tekst met de afbeelding-knop in de werkbalk.',
-    'pedit.pin_label': 'Vastpinnen bovenaan',
-    'pedit.pin_up': 'Hoger zetten',
-    'pedit.pin_down': 'Lager zetten',
-    'pedit.pin_top': 'bovenaan',
-    'pedit.pin_nth_suffix': 'e van boven',
-    'pedit.noindex_label': 'noindex (verberg voor zoekmachines)', 'pedit.nsfw_label': 'NSFW / gevoelige inhoud', 'pedit.fedi_audio_label': 'Audio openbaar delen op de fediverse (speelt inline in apps; bestand downloadbaar)', 'pedit.fedi_audio_oneway': 'Let op: openen is permanent', 'pedit.fedi_audio_locked': 'Deze audio is openbaar gedeeld op de fediverse. Dat is permanent — het bestand is al verspreid.', 'pedit.nsfw_cw_ph': 'Waarschuwingstekst (optioneel, standaard: Gevoelige inhoud)', 'post.nsfw_warning': 'Gevoelige inhoud', 'post.nsfw_show': 'Tonen', 'post.share': 'Deel', 'post.share_copied': 'Link gekopieerd ✓', 'pedit.poll_label': 'Peiling toevoegen', 'pedit.poll_locked': 'Er is al gestemd — de opties kunnen niet meer wijzigen.', 'pedit.poll_option_ph': 'Optie', 'pedit.poll_add': 'Optie toevoegen', 'pedit.poll_remove': 'Optie verwijderen', 'pedit.poll_multiple': 'Meerdere keuzes toestaan', 'pedit.poll_duration': 'Looptijd', 'pedit.poll_dur_5m': '5 minuten', 'pedit.poll_dur_30m': '30 minuten', 'pedit.poll_dur_1h': '1 uur', 'pedit.poll_dur_6h': '6 uur', 'pedit.poll_dur_12h': '12 uur', 'pedit.poll_dur_1d': '1 dag', 'pedit.poll_dur_3d': '3 dagen', 'pedit.poll_dur_7d': '7 dagen',
-    'pedit.fan_only_label': 'Alleen voor vrienden',
-    'pedit.schedule_label': 'Publicatie inplannen',
-    'pedit.schedule_hint': 'Staat dit uit, dan gaat je post meteen live. Aan = kies hieronder wanneer \'ie verschijnt.',
-    'pedit.publish_at_label': 'Datum & tijd',
-    'pedit.scheduled_for': 'Ingepland voor {d}',
-    'pedit.scheduled_prefix': 'Ingepland voor',
-    'pedit.cancel': 'Annuleren',
-    'pedit.publish': 'Publish',
-    'pedit.save': 'Opslaan',
-    'pedit.js_link_prompt': 'Link URL (https://… of /pad)',
-    'pedit.js_uploading': 'Uploaden…',
-    'pedit.js_uploaded': 'Geüpload',
-    'pedit.js_inserted': 'Ingevoegd',
-    'pedit.js_failed': 'Mislukt',
-    'pedit.js_embed_prompt': 'Plak een media-URL om in te sluiten (YouTube, Spotify, SoundCloud, Vimeo, Apple Music, Bandcamp):',
-    'pedit.js_embed_invalid': 'Geef een volledige URL (https://…).',
-    'pedit.js_no_tracks_found': 'Geen tracks gevonden voor',
-    'pedit.js_no_tracks_yet': 'Nog geen tracks. Upload via Beheer naar Audio.',
-    'pedit.js_tracks_loading': 'Tracks laden…',
-    'pedit.js_tracks_load_fail': 'Kon tracks niet laden',
-    'pedit.js_playlist_editor_missing': 'Playlist editor niet geladen',
-    'pedit.js_playlist_existing': 'Bestaande playlists:',
-    'pedit.js_playlist_choose': 'Kies een nummer om in te voegen, leeg = nieuwe maken:',
-    'pedit.chip_track': 'Track',
-    'pedit.chip_album': 'Album:',
-    'pedit.chip_playlist': 'Playlist:',
-    'pedit.tp_title': 'Track invoegen',
-    'pedit.tp_close': 'Sluiten',
-    'pedit.tp_search_placeholder': 'Zoek op titel of artiest…',
-    'pedit.tp_list_aria': 'Tracks',
-    'imed.title': 'Afbeelding bewerken',
-    'imed.rotate_left': 'Linksom draaien', 'imed.rotate_right': 'Rechtsom draaien',
-    'imed.flip_h': 'Horizontaal spiegelen', 'imed.flip_v': 'Verticaal spiegelen',
-    'imed.zoom_in': 'Inzoomen', 'imed.zoom_out': 'Uitzoomen', 'imed.reset': 'Herstellen',
-    'imed.cancel': 'Annuleren', 'imed.apply': 'Toepassen',
-    'acct.back_home': 'Terug naar home',
-    'acct.title': 'Account',
-    'acct.subtitle': 'Profiel en avatar.', 'acct.oauth_apps': 'Verbonden apps', 'acct.oauth_hint': 'Apps die je toegang tot je account hebt gegeven via OAuth. Trek in wat je niet meer vertrouwt of gebruikt.', 'acct.oauth_none': 'Nog geen apps verbonden.', 'acct.oauth_unknown_app': 'Onbekende app', 'acct.oauth_last_used': 'laatst gebruikt', 'acct.oauth_never': 'nooit', 'acct.oauth_revoke': 'Intrekken', 'acct.oauth_revoked': 'App-toegang ingetrokken.', 'acct.oauth_revoke_none': 'Die toegang bestond niet meer.',
-    'acct.viewer_mode': 'Kijker-modus',
-    'acct.viewer_note': 'Dit is een demo-account. Je kunt alles bekijken, maar niets wijzigen — ook geen foto of bio.',
-    'acct.profile': 'Profiel',
-    'acct.avatar_change': 'Klik om je foto te wijzigen',
-    'acct.member_since': 'Lid sinds', 'acct.photo_site_hint': 'Je profielfoto stel je in bij je <a href="/admin">site-instellingen</a> — één foto, overal gebruikt.',
-    'acct.avatar_remove': 'Foto verwijderen',
-    'acct.username': 'Gebruikersnaam',
-    'acct.email': 'E-mailadres',
-    'acct.email_ph': 'jij@email.nl',
-    'acct.bio': 'Bio',
-    'acct.bio_ph': 'Een korte regel over jezelf',
-    'acct.bio_empty': 'Geen bio',
-    'acct.save': 'Opslaan',
-    'acct.site': 'Site',
-    'acct.site_name': 'Site-naam',
-    'acct.site_name_hint': '— getoond in de kop van je site',
-    'acct.tagline': 'Tagline',
-    'acct.tagline_hint': '— korte oneliner (optioneel)',
-    'acct.site_save': 'Site opslaan',
-    'acct.password_change': 'Wachtwoord wijzigen',
-    'acct.password_current': 'Huidig wachtwoord',
-    'acct.password_new': 'Nieuw wachtwoord',
-    'acct.password_min': '(min 8 tekens)',
-    'acct.password_confirm': 'Bevestig nieuw wachtwoord',
-    'acct.login': 'Inloggen',
-    'acct.login_google_only': 'Dit account heeft geen wachtwoord ({email}). Gebruik "Wachtwoord vergeten" om er een in te stellen.',
-    'news.this_artist': 'deze artiest',
-    'news.form_title': 'Blijf op de hoogte',
-    'news.form_sub_before': 'Schrijf je in voor de nieuwsbrief van ',
-    'news.form_sub_after': ' — nieuwe muziek, shows en updates, rechtstreeks in je inbox. Uitschrijven kan altijd met één klik.',
-    'news.email_ph': 'jouw@email.nl',
-    'news.subscribe': 'Inschrijven',
-    'news.check_title': 'Bijna klaar ✉',
-    'news.check_sub_before': 'We hebben een bevestigingsmail gestuurd naar ',
-    'news.check_sub_after': '. Klik op de link in die mail om je inschrijving te bevestigen.',
-    'news.your_address': 'je adres',
-    'news.done_title': 'Je bent ingeschreven ✓',
-    'news.done_sub_before': 'Bedankt — je staat op de lijst van ',
-    'news.done_sub_after': '.',
-    'news.confirmed_title': 'Inschrijving bevestigd ✓',
-    'news.confirmed_sub_before': 'Top! Je ontvangt voortaan de nieuwsbrief van ',
-    'news.confirmed_sub_after': '.',
-    'news.unsubbed_title': 'Uitgeschreven',
-    'news.unsubbed_sub': 'Je bent afgemeld. Je ontvangt geen nieuwsbrieven meer. Van gedachten veranderd? Je kunt je altijd opnieuw inschrijven.',
-    'news.invalid_title': 'Ongeldig e-mailadres',
-    'news.invalid_sub': 'Controleer je adres en probeer \'t opnieuw.',
-    'news.back': '← Terug',
-    'news.smtperror_title': 'Even niet gelukt',
-    'news.smtperror_sub': 'De bevestigingsmail kon niet verstuurd worden. Probeer \'t later nog eens.',
-    'news.badtoken_title': 'Link ongeldig of verlopen',
-    'news.badtoken_sub': 'Deze link werkt niet meer. Schrijf je eventueel opnieuw in.',
-    'news.to_subscribe': 'Naar inschrijven',
-    'news.error_title': 'Er ging iets mis',
-    'news.error_sub': 'Probeer \'t later nog eens.',
-    'dl.ready_title': 'Bedankt! ⬇',
-    'dl.ready_sub': 'Je download zou nu moeten starten. Gebeurt er niets?',
-    'dl.manual': 'Download handmatig starten',
-    'dl.download_btn': 'Download',
-    'dl.capture_sub': 'Laat je e-mailadres achter en je krijgt het bestand. Je komt dan ook op de nieuwsbrieflijst — uitschrijven kan altijd.',
-    'dl.email_ph': 'jouw@email.nl',
-    'epk.kicker': 'Perskit',
-    'epk.edit': 'Bewerken',
-    'epk.contact_booking': 'Contact / boeking',
-    'epk.view_site': 'Bekijk de site →',
-    'epk.press_photo': 'Persfoto',
-    'epk.most_played': 'Meest beluisterd',
-    'epk.recent': 'Recent',
-    'fgate.title': 'Alleen voor vrienden',
-    'fgate.sub': 'Dit bericht is voor wie deze site volgt.',
-    'owa.title': 'Aanmelden bij een andere site', 'owa.sub': 'Deze site vraagt je server te bevestigen wie je bent. Ga je door, dan weet die site je adres.', 'owa.as': 'Je meldt je aan als', 'owa.choose': 'Met welke van je sites?', 'owa.go': 'Ja, meld me aan', 'owa.cancel': 'Nee, terug', 'owa.fine': 'Er gaat geen wachtwoord naar die site. Alleen je adres, en alleen als je hier ja zegt.', 'fgate.owa_label': 'Log in met je eigen fediverse-adres', 'fgate.owa_go': 'Ga verder', 'fgate.owa_hint': 'Je server bevestigt wie je bent. Hier heb je geen account en geen wachtwoord nodig — volg je deze site, dan ben je binnen.', 'fgate.owa_failed': 'Dat adres kon ik niet bereiken. Klopt de spelling?', 'fgate.login': 'Inloggen / aanmelden', 'read.open': 'Reacties en waarderingen',
-    'lbio.empty': 'Nog geen links ingesteld.',
-    'lbio.back_to_site': 'naar de site',
-    'myst.overview': 'Overzicht',
-    'myst.title': 'Mijn Klonkt Hub',
-    'myst.quick_links_aria': 'Beheer-snelkoppelingen',
-    'myst.new_post': 'Nieuwe post',
-    'myst.appearance': 'Uiterlijk',
-    'myst.comments': 'Reacties',
-    'myst.view_site': 'Bekijk mijn site',
-    'myst.account': 'Account',
-    'myst.posts': 'Posts',
-    'myst.published': 'Gepubliceerd',
-    'myst.draft_count_one': '{n} concept',
-    'myst.draft_count_many': '{n} concepten',
-    'myst.draft_badge': 'Concept',
-    'myst.untitled': '(zonder titel)',
-    'myst.edit': 'Bewerken',
-    'myst.view': 'Bekijk',
-    'arst.title': 'Wachtwoord resetten',
-    'arst.request_new': 'Vraag een nieuwe reset-link aan',
-    'arst.back_login': 'Terug naar inloggen',
-    'arst.set_for': 'Stel een nieuw wachtwoord in voor {username}.',
-    'arst.new_pw': 'Nieuw wachtwoord (min 8 tekens)',
-    'arst.confirm_pw': 'Bevestig nieuw wachtwoord',
-    'arst.submit': 'Wachtwoord instellen',
-    'arrq.title': 'Wachtwoord resetten',
-    'arrq.sent': 'Als er een account bestaat voor dat e-mailadres, is er een reset-link verstuurd.',
-    'arrq.no_mailserver': '<strong>Geen e-mailserver ingesteld</strong> — reset-link hieronder.',
-    'arrq.no_mail_cli': 'Geen e-mail ingesteld? De beheerder kan ook <code>npm run reset-admin</code> draaien op de server.',
-    'arrq.back_login': '← Terug naar inloggen',
-    'arrq.tagline': 'Vul je e-mail in; we sturen een reset-link.',
-    'arrq.email': 'E-mail',
-    'arrq.submit': 'Stuur reset-link',
-    'adir.home': 'Home',
-    'adir.logout': 'Uitloggen',
-    'adir.login': 'Inloggen',
-    'adir.title': 'Leden',
-    'adir.count_one': '{n} Klonkt',
-    'adir.count_many': "{n} Klonkt's",
-    'adir.search_ph': 'Zoek op naam…',
-    'adir.search_aria': 'Zoek leden',
-    'adir.search_btn': 'Zoeken',
-    'adir.clear': 'Wissen',
-    'adir.empty_q': 'Geen leden gevonden voor "{q}".',
-    'adir.empty': 'Nog geen leden.',
-    'adir.posts_one': '{n} post',
-    'adir.posts_many': '{n} posts',
-    'adir.pager_aria': 'Paginering',
-    'adir.prev': '← Vorige',
-    'adir.page_info': 'Pagina {page} van {pages}',
-    'adir.next': 'Volgende →',
-    'pusr.post_one': 'post',
-    'pusr.post_many': 'posts',
-    'pusr.on_this_site': 'op deze site',
-    'pusr.total': '{n} totaal',
-    'pusr.joined': 'lid sinds {date}',
-    'pusr.send_dm': 'Stuur DM',
-    'pusr.empty': 'Nog geen posts op deze site.',
-    'pusr.posts_heading': 'Posts',
-    'pusr.untitled': '(zonder titel)',
-    'phub.main_badge': 'Hoofdpagina',
-    'phub.view_page': 'Bekijk de pagina',
-    'phub.members': 'Leden',
-    'phub.post_one': 'post',
-    'phub.post_many': 'posts',
-    'phub.all_members': 'Alle {n} leden',
-    'phub.latest_posts': 'Laatste posts van onze leden.',
-    'cfeed.title': 'Cirkels',
-    'cfeed.sub': 'Wat er speelt bij de andere sites in mijn cirkel.',
-    'cfeed.count': '{n} sites in mijn cirkel',
-    'cfeed.empty': 'Nog niets in mijn cirkel.',
-    'cfeed.close': 'Sluiten',
-    'cfeed.grid_view': 'Grid-weergave',
-    'cpost.back': 'Cirkel',
-    'cpost.via': 'via',
-    'cpost.read_more': 'Lees verder bij {source}',
-    'vblk.title': 'Kijker-modus',
-    'vblk.text_before': 'Dit is een alleen-lezen account. Je kunt alles bekijken, maar',
-    'vblk.text_strong': 'niets wijzigen',
-    'vblk.text_after': '— opslaan, uploaden, verwijderen en reageren zijn uitgeschakeld.',
-    'vblk.back': 'Terug',
-    'vblk.to_home': 'Naar de hoofdpagina',
-    'phome.moved_lead': 'Dit account is verhuisd. Je vindt me nu hier:',
-    'phome.moved_hint': 'Volg je me al? Dan verhuist je server je waarschijnlijk vanzelf mee. Zo niet: volg het nieuwe adres.',
-    'phome.empty_title': 'Hier is het nog stil.',
-    'phome.empty_sub': 'Nog geen posts. Spannend.',
-    'phome.write_first': 'Schrijf je eerste post',
-    'phome.grid_view': 'Grid-weergave',
-    'fav.title': 'Favorieten',
-    'fav.sub': "Posts die je hebt geliket. Tik op het ♥ bij een post om 'm hier te bewaren.",
-    'fav.empty': 'Je hebt nog geen favorieten. Open een post en tik op het hartje ♡.',
-    'chlog.back': 'Terug',
-    'chlog.title': 'Wijzigingen',
-    'chlog.app_version': 'App-versie',
-    'chlog.fed_proto': 'Federatie-proto',
-    'chlog.manage_updates': 'Updates beheren',
-    'e404.title': 'Pagina niet gevonden',
-    'e404.sub': 'Deze pagina bestaat niet (meer). Misschien is de link verouderd of verkeerd getypt.',
-    'e404.home': 'Naar de homepagina',
-    'e404.archive': 'Archief',
-    'ptype.eyebrow': 'Type',
-    'ptype.count_one': '{n} post',
-    'ptype.count_many': '{n} posts',
-    'ptype.empty': 'Nog geen posts van dit type.',
-    'ptype.untitled': '(zonder titel)',
-    'ptag.eyebrow': 'Tag',
-    'ptag.count_one': '{n} post',
-    'ptag.count_many': '{n} posts',
-    'ptag.empty': 'Nog geen posts met deze tag.',
-    'ptag.untitled': '(zonder titel)',
-    'parch.back': 'Terug',
-    'parch.title': 'Archief',
-    'parch.count_one': '{n} bericht',
-    'parch.count_many': '{n} berichten',
-    'parch.empty': 'Nog geen berichten.',
-    'prin.tagline': 'Je directe berichten op deze site.',
-    'prin.empty': 'Nog geen gesprekken. Open iemands profiel en klik op "DM sturen" om er een te beginnen.',
-    'prin.empty_conv': 'Leeg gesprek',
-    'prcv.back_aria': 'Terug naar inbox',
-    'prcv.inbox': 'Inbox',
-    'prcv.unknown': 'Onbekend',
-    'prcv.view_profile': 'Bekijk profiel',
-    'prcv.placeholder': 'Bericht…',
-    'prcv.send': 'Stuur',
-    'acct.lang_label': 'Taal',
-    'acct.lang_hint': '— jouw persoonlijke interface-taal; reist mee over apparaten en sessies.',
-    'aset.default_lang': 'Standaardtaal voor bezoekers',
-    'aset.default_lang_hint': 'Wat nieuwe bezoekers zien voordat ze zelf een taal kiezen. Een ingelogde gebruiker met een eigen voorkeur ziet díe.',
-    'aset.default_lang_auto': 'Automatisch (browser-taal)',
-    'aset.timezone': 'Tijdzone',
-    'aset.timezone_hint': 'In welke tijdzone datums en geplande publicaties worden getoond en opgeslagen. Automatisch = serverstandaard (UTC).',
-    'aset.timezone_auto': 'Automatisch (UTC)',
-  },
-  en: {
-    'nav.back_to_site': '← Back to site',
-    'nav.fediverse': 'Fediverse',
-    'nav.home': 'Home',
-    'nav.archive': 'Archive',
-    'nav.search': 'Search',
-    'nav.theme': 'Toggle theme',
-    'nav.theme_label': 'Theme', 'nav.dark': 'Dark', 'nav.light': 'Light',
-    'nav.install': 'Install app',
-    'nav.login': 'Log in',
-    'nav.logout': 'Log out',
-    'nav.admin': 'Admin',
-    'nav.account': 'Account',
-    'nav.profile': 'Profile',
-    'nav.favorites': 'Favorites',
-    'nav.new_post': 'New post',
-    'nav.language': 'Language',
-    'nav.notifications': 'Notifications',
-    'notif.title': 'Notifications', 'notif.empty': 'No notifications yet.', 'notif.someone': 'Someone', 'notif.followed': 'followed you', 'notif.liked': 'liked your post', 'notif.boosted': 'boosted your post', 'notif.replied': 'replied to', 'notif.reported': 'reported you to their server', 'notif.report_about': 'About the post', 'notif.report_noreason': 'No reason given.', 'notif.mentioned': 'mentioned you in a post', 'blk.title': 'Blocking', 'blk.lead': 'Block an account or a whole domain — their replies, likes and posts disappear and new ones are refused.', 'blk.block_btn': 'Block', 'blk.empty': 'Nothing blocked.', 'blk.unblock': 'Unblock', 'tl.block': 'Block',
-    'notif.reply': '{actor} replied to your comment', 'notif.comment': '{actor} commented on your post', 'notif.like': '{actor} liked your post',
-    'switch.agenda': 'Agenda',
-    'switch.solo': 'Solo',
-    'switch.circle': 'Circles',
-    'switch.grid': 'Grid', 'switch.reader': 'Reader', 'switch.timeline': 'Timeline', 'read.to_top': 'Back to top', 'read.pinned': 'Pinned', 'read.next': 'Next', 'read.prev': 'Previous post', 'read.nav': 'Through the posts', 'read.hint': 'Tap the top or bottom to move a post back or on', 'asite.reader_full_page': 'Reader on desktop: one screen per post (always on mobile)',
-    'asite.feed_alt': 'Second view', 'asite.feed_alt_reader': 'Reader', 'asite.feed_alt_timeline': 'Timeline', 'asite.feed_alt_auto': 'Reader on mobile, Timeline on desktop',
-    'switch.reader_solo_only': 'Reader is Solo only — a circle shows other people\u2019s posts',
-    'postnav.newer': 'Newer',
-    'postnav.older': 'Older',
-    'postnav.newest': 'Newest post',
-    'postnav.oldest': 'Oldest post',
-    'footer.subscribe_cta': 'Stay in the loop',
-    'footer.subscribe': 'Subscribe',
-    'footer.install': 'Install app',
-    'common.email_placeholder': 'you@email.com',
-    'common.back_to_admin': '← Admin',
-    'agenda.title': 'Agenda',
-    'agenda.empty': 'No announced events at the moment.',
-    'agenda.tickets': 'Tickets',
-    'agenda.notify_h': 'Never miss an event',
-    'agenda.notify_sub': 'Leave your email and we’ll let you know about new events. Unsubscribe any time.',
-    'agenda.notify_btn': 'Keep me posted',
-    'agenda.msg_done': '✓ You’re on the list — we’ll let you know when an event is announced.',
-    'agenda.msg_check': '✉ Check your email to confirm your sign-up.',
-    'downloads.title': 'Downloads',
-    'downloads.sub': 'Free to download — leave your email and you’ll get the file.',
-    'downloads.empty': 'No downloads available right now.',
-    'downloads.btn': '⬇ Download',
-    'admin.title': 'Admin',
-    'adash.prem_active': 'Premium active',
-    'adash.prem_unlinked': 'Premium not linked',
-    'adash.prem_layeroff': 'Premium layer off',
-    'admin.tagline_solo': 'Solo mode — your site.',
-    'admin.tagline_cirkels': 'Circles mode: your site, connected to the fediverse.',
-    'admin.b_paid': 'Paid posts', 'admin.b_push': 'Notifications', 'admin.back': 'Back to Admin',
-    'push.t': 'Notifications', 'push.intro': 'Get a notification on this device for new followers, replies and messages, even when the site is closed. Encrypted all the way to your browser; we send as little content as possible.', 'push.unavailable': 'Push is unavailable on this server (the key could not be created or the dependency is missing).', 'push.unsupported': 'This browser does not support push notifications.', 'push.ios_hint': 'On iPhone/iPad this only works when the site is on your home screen: share button, then "Add to Home Screen", and open it from there.', 'push.this_device': 'This device:', 'push.checking': 'checking…', 'push.state_on': 'notifications are on', 'push.state_off': 'notifications are off', 'push.state_denied': 'blocked in the browser settings', 'push.state_unknown': 'status unknown', 'push.state_unsupported': 'not supported', 'push.enable': 'Turn on for this device', 'push.disable': 'Turn off', 'push.test': 'Send a test notification', 'push.what': 'What do you want to be notified about?', 'push.a_follow': 'New follower', 'push.a_reply': 'Reply or mention', 'push.a_like': 'Like (star)', 'push.a_boost': 'Boost', 'push.a_dm': 'Private message', 'push.saved': 'Saved.', 'push.devices': 'Linked devices', 'push.device': 'Device', 'push.since': 'since', 'push.remove': 'Remove', 'push.enable_failed': 'turning on failed',
-    'push.n_follow_t': 'New follower', 'push.n_follow_b': '{who} now follows you', 'push.n_folreq_t': 'Follow request', 'push.n_folreq_b': '{who} wants to follow you — you decide', 'push.n_reply_t': 'Reply to "{title}"', 'push.n_mention_t': 'Mention', 'push.n_dm_t': 'Private message', 'push.n_dm_b': 'New message from {who}', 'push.n_like_t': 'New like', 'push.n_like_b': '{who} liked "{title}"', 'push.n_boost_t': 'Boosted', 'push.n_boost_b': '{who} boosted "{title}"', 'msg.guard_offer': 'wants to become your guardian. Talk this over with your parents or carers before you decide.', 'msg.guard_accept': 'Accept', 'msg.guard_reject': 'Reject', 'msg.guard_accepted': 'Guardian accepted. You are now connected.', 'msg.guard_rejected': 'Offer rejected.', 'msg.guard_failed': 'That did not work; try again.', 'msg.guardians_label': 'Your guardians', 'msg.waved_at_you': 'waved at you', 'msg.help_request': 'asked for help', 'msg.g_available': 'available', 'msg.g_away': 'unavailable till {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Lovely!', 'msg.wave_r2': 'Call me', 'msg.wave_back': '👋 Back', 'msg.wave_sent': 'Wave sent.', 'msg.reply_sent': 'Reply sent.', 'msg.reply_failed': 'The reply could not be sent.', 'msg.reply_empty': 'An empty reply cannot be sent.', 'guardian.feed_title': 'Your wards', 'guardian.feed_sub': 'Read along with what your wards post. Watch only.', 'guardian.follow_title': 'Follow requests', 'guardian.follow_sub': 'Someone wants to follow one of your wards. You decide.', 'guardian.wave': '👋 Wave', 'guardian.waved': '👋 sent', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Manage wards and catch calls for help.', 'guardian.acting_as': 'You act as', 'guardian.help_title': 'Help requests', 'guardian.help_sub': 'When a ward uses the help buoy, it shows up here.', 'guardian.help_empty': 'No help requests. Good.', 'guardian.adopt_title': 'Adopt a ward', 'guardian.adopt_sub': 'Enter the child handle (@kid@server.eu). They get an offer in their Klonkt to accept.', 'guardian.adopt_label': 'Ward handle', 'guardian.adopt_btn': 'Send offer', 'guardian.pending_title': 'Sent offers', 'guardian.pending_sub': 'Waiting for the ward to accept.', 'guardian.wards_title': 'My wards', 'guardian.play_propose': 'Propose in-app playback', 'guardian.play_on': 'Playback: on', 'guardian.play_off': 'Playback: off', 'guardian.gated_title': 'Setting proposed', 'guardian.gated_line_on': '{who} wants to turn link previews ON for {ward}.', 'guardian.gated_line_off': '{who} wants to turn link previews OFF for {ward}.', 'guardian.gated_agree': 'Agree', 'guardian.gated_disagree': 'Disagree', 'guardian.avail_available': 'Available', 'guardian.avail_away': 'Unavailable till {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians of this child', 'guardian.panel_guards_remote': 'This child lives on another server; availability is tracked there.', 'guardian.lapse_propose': 'Propose release in absentia', 'guardian.lapse_line': '{who} has stopped answering as a guardian of {ward}.', 'guardian.lapse_tally': '{n} of {need} agreed; closes {date}; the window always runs in full.', 'guardian.lapse_note': 'Any sign of life from them cancels this outright. Nothing here is punishment.', 'guardian.lapse_agree': 'Agree', 'guardian.lapse_disagree': 'Disagree', 'guardian.voted': 'You voted', 'guardian.away_title': 'Step away', 'guardian.away_sub': 'Tell your wards you are unavailable for a while. Decisions will not wait for you, and one answer brings you straight back.', 'guardian.away_week': 'A week', 'guardian.away_month': 'A month', 'guardian.away_done': 'Your wards know you are unavailable until {date}.', 'guardian.away_msg': 'I am unavailable as your guardian until {date}. Your other guardians are there for you.', 'guardian.release_title': 'Release {who}?', 'guardian.release_effect': 'You stop being their guardian. You will no longer see their posts, no longer receive their calls for help, and no longer decide on follow requests for them. Coming back means a fresh offer that they accept.', 'guardian.release_local': 'Their server and the other guardians are told, so afterwards you are no longer listed as a guardian there either.', 'guardian.release_step_down': 'They keep their other guardians, so they stay a ward.', 'guardian.release_last': 'You are their last guardian. That is emancipation, and FEP-633c 3.4 is explicit that no single guardian decides it: it takes three consenting adults, or a majority plus two witnesses. So this button cannot do it: you stay their guardian until that is arranged.', 'guardian.release_unknown': 'We could not reach their server, so we do not know whether you are their last guardian.', 'guardian.release_yes': 'Yes, release', 'guardian.release_no': 'No, keep them', 'guardian.settings_title': 'Settings', 'guardian.panel_open': 'Open', 'guardian.panel_close': 'Close', 'guardian.panel_help': 'Calls for help from this child', 'guardian.panel_help_empty': 'No calls for help yet.', 'guardian.panel_follow': 'Follow requests', 'guardian.follow_out_line': 'wants to follow {who}', 'guardian.panel_follow_empty': 'No follow requests waiting.', 'guardian.panel_posts': 'Recent posts', 'guardian.panel_posts_empty': 'Nothing here yet.', 'guardian.panel_actions': 'Actions', 'guardian.badge_help': 'calls for help', 'guardian.badge_follow': 'follow requests', 'guardian.badge_follow_one': 'follow request', 'guardian.wards_empty': 'No wards yet. Adopt one above.', 'guardian.push_title': 'Notifications', 'guardian.push_sub': 'Get notified on a call for help or a guardianship answer, even with the app closed.', 'guardian.push_on': 'Turn on notifications', 'guardian.push_off': 'Notifications are on; tap to turn off', 'guardian.sent': 'Offer sent. See it below under Sent offers.', 'guardian.sent_retry': 'Offer saved; we keep trying to deliver it.', 'guardian.sending': 'Sending…', 'guardian.not_found': 'We could not find that handle.', 'guardian.failed': 'Failed', 'guardian.network': 'Network error.', 'guardian.pending': 'awaiting answer', 'guardian.active': 'active', 'guardian.retract': 'Retract', 'guardian.release': 'Release', 'guardian.embeds_on': 'Link previews: on', 'guardian.embeds_off': 'Link previews: off', 'guardian.embeds_propose': 'Propose link previews', 'guardian.embeds_waiting': 'waiting for the other guardians', 'guardian.prop_line': 'Proposal {what} {value}: {status}', 'guardian.prop_embeds': 'link previews', 'guardian.prop_play': 'playback', 'guardian.prop_on': 'on', 'guardian.prop_off': 'off', 'guardian.prop_st_open': 'waiting for the other guardians', 'guardian.prop_st_accepted': 'accepted', 'guardian.prop_st_rejected': 'rejected', 'guardian.prop_st_expired': 'expired without enough votes', 'guardian.panel_guards_far': 'Availability is tracked on their server.', 'guardian.release_confirm': 'Release {who}?\n\nYou stop being their guardian. You will no longer see their posts, no longer receive their calls for help, and no longer decide on follow requests for them.\n\nComing back means a fresh offer that they accept.', 'guardian.open': 'open', 'guardian.accept': 'Accept', 'guardian.reject': 'Reject', 'guardian.complete': 'Complete', 'guardian.awaiting_others': 'awaiting the other parties', 'guardian.coguard': 'co-guardianship offer', 'guardian.push_unavailable': 'Push unavailable', 'push.n_help_t': 'Call for help', 'push.n_help_b': '{who} is asking for your help', 'push.n_guard_offer_t': 'Guardianship offer', 'push.n_guard_offer_b': '{who} wants you as their guardian', 'push.n_guard_ward_t': 'Ward accepted', 'push.n_guard_left_t': 'A guardian has stepped down', 'push.n_guard_left_b': '{who} is no longer your guardian', 'push.n_guard_cogleft_t': 'Co-guardian stepped down', 'push.n_guard_cogleft_b': '{who} has ended their guardianship', 'push.n_guard_ward_b': '{who} accepted you as guardian', 'push.n_guard_cog_t': 'Co-guardianship asked', 'push.n_guard_cog_b': 'A guardian offer for {who} needs you', 'push.n_guard_folin_t': 'Follow request', 'push.n_guard_folin_b': '{who} wants to follow {ward}', 'push.n_guard_folout_t': 'Your ward wants to follow someone', 'push.n_guard_folout_b': '{ward} is asking to follow {who}', 'guardian.panel_history': 'History ({n})', 'guardian.log_show': 'Show history', 'guardian.log_hide': 'Hide history', 'guardian.ev_offer_rejected': 'Offer rejected', 'guardian.ev_offer_refused': 'Offer refused', 'guardian.ev_committed': 'Guardianship committed', 'guardian.ev_guardian_left': 'Guardian left', 'guardian.ev_coguardian_left': 'Co-guardian left', 'guardian.ev_gated_outcome': 'Gate decided', 'guardian.ev_lapse_opened': 'Release proposed', 'guardian.evr_not_a_teapot': 'the candidate is a ward themselves', 'guardian.help_archive': '{n} handled', 'guardian.help_archive_hide': 'hide', 'guardian.help_former_ward': 'No longer your ward. Their other guardians are still there for them.', 'guardian.warn_reversible': 'What comes through does not go back. You can close this setting again later — what your child has seen, you cannot.', 'guardian.warn_irreversible': 'This cannot be undone. From then on they decide for themselves, and you cannot take that back.', 'guardian.warn_unknown': 'We do not know this setting, so we cannot tell what comes through or how far it reaches. Ask whoever proposed it before you agree.', 'guardian.warn_decides': 'YOUR ANSWER SETTLES THIS. With yours the threshold is met and it takes effect straight away.', 'guardian.warn_not_last': 'Someone else still has to answer before this takes effect.', 'guardian.warn_tally_elsewhere': 'We cannot see how many guardians have answered — the child\u2019s server counts that. Your yes may be the deciding one.', 'guardian.warn_go': 'Yes, propose this', 'guardian.warn_back': 'No, go back', 'guardian.help_pick': 'I am on it', 'guardian.help_close': 'Mark as handled', 'guardian.help_picked_by': '{who} is looking into this', 'guardian.help_handled_by': 'Handled by {who}', 'guardian.help_handled_note': 'This stays. If it is still going on, the child asks again.', 'guardian.help_close_ask': 'Are you sure? This cannot be undone. If it is still going on, the child will ask for help again.', 'guardian.help_close_yes': 'Yes, handled', 'guardian.help_just_now': 'just now', 'guardian.help_hours': '{n}h ago', 'guardian.help_days': '{n}d ago', 'guardian.gate_unavailable': 'not available yet', 'guardian.gate_planned_note': 'This does not exist on this server yet.', 'guardian.gates_summary': '{n} gates - {on} on, {wait} waiting', 'guardian.gates_show': 'Show gates', 'guardian.gates_hide': 'Hide gates', 'guardian.gate_images': 'Images', 'guardian.gate_messages': 'Messages', 'guardian.gate_asked': 'Your child asked for this.', 'guardian.gate_replies': 'Replying in a conversation', 'guardian.gate_compose': 'Posting', 'guardian.gate_music': 'Music', 'guardian.gate_quoteCards': 'Quote cards', 'guardian.gate_customEmoji': 'Custom emoji', 'guardian.gate_publicProfile': 'Publicly visible', 'guardian.gate_accountMove': 'Moving account', 'guardian.gate_independence': 'Becoming independent', 'guardian.gate_externalThreads': 'Replies from strangers', 'guardian.gate_externalEmbeds': 'Link previews', 'guardian.gate_externalPlayback': 'In-app playback', 'guardian.gate_follows': 'Follow requests', 'guardian.gate_following': 'Following others', 'guardian.gate_kind_setting': 'setting', 'guardian.gate_kind_perRequest': 'per request', 'guardian.gate_kind_handover': 'hands over authority', 'guardian.gate_default_off': 'off (nothing decided yet)', 'guardian.gate_unknown': 'unknown', 'guardian.gate_always': 'Always', 'guardian.gate_threshold': '{need} of {of} guardians', 'guardian.gate_threshold_unknown': 'threshold unknown (other domain)', 'guardian.gate_irreversible': 'cannot be undone', 'guardian.gate_waiting': '{n} waiting on you', 'guardian.gate_blocked': 'needs {what} on first', 'guardian.gate_propose_open': 'Propose: open', 'guardian.gate_propose_close': 'Propose: close', 'guardian.gate_propose': 'Propose', 'push.n_gate_ask_t': 'Your answer is needed', 'push.n_gate_ask_b': 'Proposal for {who}: {wat} {stand}', 'push.n_gate_done_t': 'Decision reached', 'push.n_gate_done_b': '{wat} {stand} for {who}: {uitkomst}', 'push.n_test_t': 'Klonkt test notification', 'push.n_test_b': 'It works. This is how notifications arrive on this device.',
-    'apaid.t': 'Paid posts', 'apaid.intro': 'Connect your own Patreon campaign. Supporters unlock paid posts with a passkey, no account and no cookie. We store no supporter names or email addresses, only the encrypted token of your campaign.', 'apaid.saved': 'Saved.', 'apaid.nokey': 'Note: the encryption key could not be created or read (write permissions on the storage directory?). Without a key, secrets cannot be stored safely.', 'apaid.status': 'Status:', 'apaid.connected': 'connected', 'apaid.campaign': 'campaign', 'apaid.configured': 'configured, not connected yet (enter a token)', 'apaid.notyet': 'not configured yet', 'apaid.redirect_h': 'Put this redirect URI in your Patreon client', 'apaid.redirect_p': 'In your Patreon API client, under Redirect URIs, exactly this line must be present. If it does not match, Patreon shows an error instead of sending your supporters back.', 'apaid.copy': 'Copy', 'apaid.copied': 'Copied', 'apaid.client_id': 'Patreon client id', 'apaid.client_secret': 'Patreon client secret', 'apaid.keep': 'Leave empty = keep the current value.', 'apaid.campaign_id': 'Campaign id', 'apaid.public_page': 'Public Patreon page', 'apaid.public_help': 'The link where visitors can become a supporter. Shown as "Become a supporter" when someone does not pledge yet.', 'apaid.access': 'Creator access token', 'apaid.refresh': 'Creator refresh token', 'apaid.token_help': 'You get the access + refresh token on your Patreon API client page. We encrypt them and refresh automatically.', 'apaid.min_eur': 'Default support amount for a paid post (euro)', 'apaid.save': 'Save', 'apaid.disconnect': 'Remove connection', 'apaid.disconnect_confirm': 'Remove the Patreon connection?', 'apaid.unchanged': 'stays unchanged',
-    'pgate.h': 'For supporters', 'pgate.sub': 'This post is for supporters of this site. Become a supporter and then unlock it with a passkey. No account on this site, no cookie.', 'pgate.sub_cents': 'This post is for supporters of this site (from €{eur} per month on Patreon). Become a supporter and then unlock it with a passkey. No account on this site, no cookie.', 'pgate.join': 'Become a supporter on Patreon', 'pgate.unlock_have': 'Already a supporter? Unlock', 'pgate.unlock': 'Unlock with Patreon', 'pgate.join_short': 'Become a supporter', 'pgate.confirm': 'Confirm with your passkey…', 'pgate.failed': 'Unlocking failed. Try again.', 'pgate.error': 'Something went wrong. Try again.',
-    'ppk.t': 'Create your passkey', 'ppk.h': 'You are a supporter, nice.', 'ppk.sub': 'Now create a passkey. It becomes your key for paid posts, without an account and without a cookie. We store no name or email address.', 'ppk.make': 'Create passkey', 'ppk.unsupported': 'Passkeys are not supported in this browser.', 'ppk.follow': 'Follow the prompt on your device…', 'ppk.done': 'Done. Your passkey has been created.', 'ppk.failed': 'Creating failed ({err}). Try again.', 'ppk.cancelled': 'Cancelled.',
-    'pres.t': 'Unlock', 'pres.notpatron_h': 'Not a supporter yet', 'pres.notpatron_p': 'You are not (yet) an active supporter of this site on Patreon. Become a supporter and then try again from the post.', 'pres.tier_h': 'A higher tier is needed', 'pres.tier_p': 'This post asks from €{need}. Your support is currently €{have}. Raise your support and try again.', 'pres.expired_h': 'Request expired', 'pres.expired_p': 'This unlock link has expired or was already used. Go back to the post and try again.', 'pres.declined_h': 'Unlocking cancelled', 'pres.declined_p': 'Nothing was connected. You can try again from the post.', 'pres.join': 'Become a supporter on Patreon', 'pres.back_post': 'Back to the post', 'pres.back_site': 'Back to the site',
-    'admin.tagline_hub': 'Hub mode — a company site with users, each their own Klonkt Hub.',
-    'admin.b_sites': '🌐 Sites', 'admin.b_users': '👥 Users', 'admin.b_audio': '🎵 Audio',
-    'admin.b_media': '🎬 Media', 'admin.t_media': 'Media', 'admin.media_images': 'Images', 'admin.media_videos': 'Videos', 'admin.videos_count': 'videos', 'admin.videos_empty': 'No videos uploaded yet.', 'admin.videos_del_confirm': 'Delete this video?', 'admin.media_count': 'images', 'admin.media_unused': 'unused', 'admin.media_cleanup': 'Delete unused', 'admin.media_cleanup_confirm': 'Delete all unused images?', 'admin.media_empty': 'No images uploaded yet.', 'admin.media_copy': 'Copy URL', 'admin.media_del_confirm': 'Delete this image?',
-    'admin.b_playlists': '📃 Playlists', 'admin.b_comments': '💬 Comments', 'admin.b_seo': '🔎 SEO',
-    'admin.b_listeners': 'Listeners',
-    'alis.lis_title': 'Listeners',
-    'alis.lis_intro': 'Accounts following your LIBRARY. They get your music, and deliberately not your ordinary posts — subscribing to a record shelf is not asking for the newspaper.',
-    'alis.lis_empty': 'Nobody follows the library yet.',
-    'alis.lis_since': 'Since',
-    'alis.lis_last': 'Last delivery',
-    'alis.lis_never': 'nothing delivered yet',
-    'alis.lis_count': 'listener(s)',
-    'alis.lis_error': 'delivery failed',
-    'ple.titel': 'Title *',
-    'ple.artiest': 'Artist',
-    'ple.jaar': 'Year',
-    'ple.type': 'Type',
-    'ple.k_album': 'Album (numbered)',
-    'ple.k_playlist': 'Playlist (track covers)', 'ple.k_mixtape': 'Mixtape (a tape: forwards and back only)',
-    'ple.uitgave': 'Release date',
-    'ple.mb_release': 'MusicBrainz release ID',
-    'ple.cover': 'Cover',
-    'ple.cover_kies': 'Choose image…',
-    'ple.cover_url': 'https://… or upload',
-    'ple.tracks_in': 'Tracks in playlist',
-    'ple.sleep_hint': '(drag ⠿ to reorder)',
-    'ple.beschikbaar': 'Available tracks',
-    'ple.zoek': 'Search…',
-    'ple.geen_res': 'No results.',
-    'ple.leeg_sel': 'Click tracks on the right to add them.',
-    'ple.t_edit': 'Edit playlist',
-    'ple.t_new': 'New playlist',
-    'ple.dialoog': 'Playlist editor',
-    'ple.sluiten': 'Close',
-    'ple.annuleren': 'Cancel',
-    'ple.opslaan': 'Save',
-    'ple.bezig_opslaan': 'Saving…',
-    'ple.aanmaken': 'Create',
-    'ple.versleep': 'Drag',
-    'ple.verwijder': 'Remove',
-    'ple.geen_audio': 'Track has no audio file',
-    'ple.e_geen_tracks': 'No audio tracks available. Upload some first via Admin → Audio.',
-    'ple.e_tracks': 'Could not load tracks',
-    'ple.e_opslaan': 'Saving failed: ',
-    'ple.e_mislukt': 'Failed: ',
-    'ple.e_alleen_afb': 'Images only',
-    'ple.bezig': 'Uploading…',
-    'ple.e_upload': 'Upload failed',
-    'admin.b_settings': '⚙️ Settings', "mig.title": "Migrate", "mig.plan_new": "on your new Klonkt", "mig.plan_from_old": "You are looking at the instance that is LEAVING. Steps 1, 3 and 4 happen on your new Klonkt.", "mig.plan_title": "How a move works", "mig.plan_hint": "The order matters. Step 2 must come before step 3, because your old Klonkt gives nothing to an address it does not know as its successor. Working from a zip? Then step 2 can wait until last: a file asks nobody for permission.", "mig.plan_here": "here", "mig.plan_old": "on your old Klonkt", "mig.plan_1": "Link your previous account", "mig.plan_1_why": "Skip this and your old Klonkt refuses the move.", "mig.plan_2": "Announce the move", "mig.plan_2_why": "Your followers come along. Your old account locks afterwards: no more posting, following or replying there.", "mig.plan_3": "Fetch your posts and music", "mig.plan_3_why": "Straight from your old Klonkt, or from a zip if it is already offline.", "mig.plan_4": "Restore your following list", "mig.plan_5": "Later: when you give up the old domain", "mig.plan_5_why": "Delete your old posts there first. Then someone else’s boost disappears cleanly instead of sitting there as a broken card with a dead link. After that, remove your previous account at step 1 above: you no longer control that address. There is no button for this yet.", "mig.follows_note": "Or paste the contents of your CSV above. The file wins if you fill in both.", "asite.moved_to_migrate": "Aliases and moving now live under Migrate, together with export, import and fetch.", "mig.alias_title": "Step 1: link your previous account", "mig.alias_hint": "Tell us which account used to be yours. Your old Klonkt checks this before it moves your followers, and the fetch button below needs it too.", "mig.alias_label": "Your previous accounts, one per line", "mig.alias_note": "The way you would give them to someone: @you@mastodon.social. Five at most.", "mig.alias_btn": "Save", "mig.move_title": "Step 2: announce the move", "mig.move_hint": "This tells all your followers that your account now lives elsewhere. They move with you, and this account locks afterwards: no more posting, following, liking or replying.", "mig.move_label": "Your new address", "mig.move_warn": "This is the only button on this page you cannot undo. Your old account locks afterwards. Using the fetch button? Then this MUST come first: otherwise your old Klonkt hands over nothing.", "mig.move_btn": "Announce the move", "mig.move_confirm": "This announces your move to all your followers and locks this account. Are you sure?", "mig.move_done": "This account already moved to", "mig.r_links_fixed": "posts whose links now point here", "mig.r_posts_updated": "posts repaired (images made local)", "mig.r_tracks_updated": "tracks completed", "mig.r_tracks_missing": "{n} track(s) did NOT arrive, because the audio file was absent. They were deliberately not created: a track that sits in the list and will not play is worse than one that is missing.", "mig.c_tracks": "tracks", "mig.c_playlists": "playlists", "mig.audio_missing": "Note: for {n} track(s) the audio file cannot be found. Those will not travel.", "mig.audio_none": "Note: this site has music, but not a single track is travelling. The files are probably somewhere other than the database thinks.", "mig.pull_title": "Fetch from your old Klonkt", "mig.pull_hint": "If you already moved, this Klonkt fetches your posts straight from the old one. Nothing to type over: having moved is the proof.", "mig.pull_source": "Your old account", "mig.pull_source_hint": "Taken from step 1. Change it here if it is wrong.", "mig.pull_btn": "Fetch posts", "mig.pull_done": "Fetched", "mig.pull_failed": "Fetching did not work", "mig.r_blocks": "blocks carried over", "mig.state": "{n} posts have a pointer from their old address.", "mig.state_done": "The list is complete.", "mig.state_busy": "The list is not complete yet.", "mig.e_no_source": "No old account is known. Fill in the address above.", "mig.e_unreachable": "The old Klonkt cannot be reached.", "mig.e_not_moved_here": "That account has not moved here. Announce the move on your old Klonkt first.", "mig.e_no_backreference": "This account does not say anywhere that the old account was yours. Fill it in at step 1 above, otherwise your old Klonkt cannot tell it is you.", "mig.e_no_outbox": "The old Klonkt has no post list.", "mig.e_partial": "Stopped halfway. What arrived stays; try again.", "mig.e_config": "This Klonkt does not know its own address.", "mig.e_crash": "Something unexpected went wrong.", "mig.e_points_at": "That account points at:", "mig.lead": "Take your posts, music and photos to another Klonkt, or bring them here.", "mig.export_title": "Take with you", "mig.export_hint": "This builds a zip with your posts, their replies, and the media that belongs to them.", "mig.c_posts": "posts", "mig.c_replies": "replies", "mig.c_media": "media files", "mig.c_following": "follows", "mig.c_size": "in size", "mig.missing": "Note: for {n} media reference(s) the file is no longer on disk. Those will not travel.", "mig.too_big": "This archive is too large for the web interface. Use scripts/export-archive.mjs on the server.", "mig.export_btn": "Download archive (.zip)", "mig.export_none": "Nothing to export yet.", "mig.import_title": "Bring here", "mig.import_hint": "Pick your file. You will see what would happen first; nothing changes yet.", "mig.file_label": "Your archive file (.zip)", "mig.overwrite_label": "Replace posts that are already here", "mig.overwrite_hint": "Normally everything already here is left alone. Turn this on and a post with the same name gets replaced, even if it was something else. That does not come back.", "mig.check_btn": "Check", "mig.check_hint": "This changes nothing yet. You will see what would happen first.", "mig.r_dry": "What would happen", "mig.r_done": "Imported", "mig.r_would": "would be added", "mig.r_imported": "added", "mig.r_skipped": "skipped (already here)", "mig.r_overwritten": "overwritten", "mig.r_media": "media files", "mig.r_media_missing": "media missing from the archive", "mig.r_new_ids": "This archive comes from a different web address, so your posts live at a new address here. Other servers do not know that yet, so replies and boosts pointing at the old address stay where they are.", "mig.r_confirm_hint": "Looks right? Pick the same file once more to actually do it.", "mig.r_confirm_btn": "Import for real", "mig.follows_title": "Who you follow", "mig.follows_hint": "Your following list is in the archive too, and you can download and restore it separately under Connect.", "mig.follows_btn": "Go to Connect", "admin.b_migrate": "\ud83d\udce6 Migrate", 'admin.b_newpost': '✍️ New post', 'admin.b_look': '🎨 Appearance',
-    'admin.t_admin': 'Admin', 'admin.t_settings': 'Settings', 'admin.t_audio': 'Audio tracks', 'admin.t_epk': 'Edit press kit', 'admin.t_newsletter': 'Newsletter', 'admin.t_playlists': 'Playlists', 'admin.t_seo': 'SEO', 'admin.t_shows': 'Agenda', 'admin.t_sites': 'Sites', 'admin.t_newsite': 'New site', 'admin.t_editsite': 'Edit: {title}', 'admin.t_stats': 'Statistics', 'admin.t_updates': 'Updates', 'admin.t_users': 'Users', 'admin.t_hub': 'My Klonkt Hub', 'admin.t_manual': 'Manual', 'aset.premium_gate': '{feature} is a premium feature — connect Patreon in Admin → Settings.',
-    'admin.b_makesite': '🎨 Create your site', 'admin.b_circle': '🔗 Circle', 'admin.b_stats': '📊 Statistics',
-    'admin.b_newsletter': '✉️ Newsletter', 'admin.b_perskit': '📰 Press kit', 'admin.b_downloads': '⬇ Downloads',
-    'admin.b_linkbio': '🔗 Link-in-bio', 'admin.b_agenda': '📅 Agenda',
-    'admin.b_updates': '🔄 Updates', 'admin.b_help': '📖 Manual', 'admin.b_fediverse': 'My replies',
-    'admin.st_users': 'Users', 'admin.st_sites': 'Sites', 'admin.st_posts': 'Posts', 'admin.st_published': 'Published',
-    'admin.sec_posts': 'Posts', 'admin.sec_sites': 'Sites', 'admin.sec_users': 'Users',
-    'admin.draft': 'Draft', 'admin.edit': 'Edit', 'admin.view': 'View',
-    'admin.th_slug': 'Slug', 'admin.th_title': 'Title', 'admin.th_owner': 'Owner', 'admin.th_created': 'Created',
-    'admin.th_username': 'Username', 'admin.th_email': 'Email', 'admin.th_role': 'Role', 'admin.th_joined': 'Joined',
-    'welcome.title': 'Welcome to Klonkt',
-    'welcome.tagline': 'A self-hosted publishing platform.',
-    'welcome.have_account': 'Already have an account? Log in',
-    'welcome.note': 'Create your admin account to get started — registration closes after that.',
-    'welcome.nosite': 'Hi {user}! No site is configured yet.',
-    'auth.admin_login_title': 'Admin login',
-    'auth.username_or_email': 'Username or email',
-    'auth.password': 'Password',
-    'auth.forgot': 'Forgot password?',
-    'auth.public_sub': 'Log in to comment and save your favorites.',
-    'auth.admin_box_q': 'Admin?',
-    'auth.admin_box_sub': 'Log in with username & password',
-    'auth.create_admin': 'Create admin',
-    'auth.reg_intro': 'First-time setup — create your admin account. This can only be done once.',
-    'setup.title': 'Set up your Klonkt',
-    'setup.intro': 'Welcome! Let’s get your site ready — it takes a minute. Pick your language first.',
-    'setup.lang_label': 'Language',
-    'setup.f_sitename': 'Your site’s name',
-    'setup.sitename_ph': 'e.g. your artist name',
-    'setup.submit': 'Create my site',
-    'setup.username_note': 'This becomes your address on the fediverse and can’t be changed later:',
-    'changelog.title': 'Changelog', 'changelog.empty': 'No changelog available.',
-    'auth.f_username': 'Username (3-32 chars, letters/digits/_-)',
-    'auth.f_email': 'Email',
-    'auth.f_password': 'Password (min 8 chars)',
-    'tab.home': 'Home', 'tab.search': 'Search', 'tab.write': 'Write', 'tab.profile': 'Profile',
-    'comments.heading_one': '{n} comment', 'comments.heading_other': '{n} comments',
-    'comments.empty': 'No comments yet.',
-    'fedi.heading': 'From the fediverse', 'fedi.likes': 'favourites', 'fedi.boosts': 'boosts', 'fedi.replies': 'Replies from the fediverse',
-    'fedi.reply': 'Reply', 'fedi.reply_ph': 'Your reply to the fediverse…', 'fedi.send': 'Send', 'fedi.you': 'You',
-    'fedi.remote_title': 'Reply via the fediverse', 'fedi.follow_heading': 'Follow via the fediverse', 'fedi.profile_follow': 'Follow via the fediverse', 'profile.since': 'On Klonkt since', 'profile.free': 'Free', 'fedi.follow_intro': 'You are about to follow:', 'fedi.follow_btn': 'Follow', 'fedi.cancel': 'Cancel', 'fedi.followed_title': 'Follow request sent ✅', 'fedi.followed_done': 'Your follow request is on its way. Once accepted, their posts show up in your timeline.', 'fedi.view_profile': 'View profile →', 'fedi.remote_reply': 'Reply via the fediverse', 'fedi.remote_prompt': 'Your fediverse address:', 'fedi.remote_notfound': 'Could not fetch that post. Paste the full post URL:', 'fedi.remote_load': 'Fetch', 'fedi.remote_replying_to': 'Replying to', 'fedi.remote_as': 'Sent as {site}.', 'fedi.remote_view_original': 'View the full post + comments on the source →', 'fedi.remote_reply_short': 'via the fediverse', 'fedi.like_short': 'Like', 'fedi.unlike_short': 'Unlike', 'fedi.boost_short': 'Boost', 'fedi.remote_ph': 'your server', 'fedi.remote_sent_title': 'Sent ✅', 'fedi.remote_sent': 'Your reply has been sent. It will show up on the original post on the fediverse, not on this page. See it there:', 'fedi.reply_where': 'Your reply appears on the original post on the fediverse, not on this page. Use the link above to see it there.', 'fedi.remote_back': '← Back to your site', 'fedi.like_btn': 'Like this post', 'fedi.or_reply': 'or reply:', 'fedi.liked_title': 'Liked', 'fedi.liked_done': 'Your like is on its way to the fediverse.', 'fedi.boost_btn': 'Boost this post', 'fedi.boosted_title': 'Boosted', 'fedi.boosted_done': 'Your boost is on its way to the fediverse.', 'fedi.remote_interact': 'Interact via the fediverse', 'fedi.report_open': 'Report this post', 'fedi.report_where': 'The report goes to the instance and their moderator(s).', 'fedi.report_ph': 'What is wrong? (optional)', 'fedi.report_send': 'Report', 'fedi.reported_title': 'Reported', 'fedi.reported_done': 'Your report has been sent to their server. Their moderators will review it.', 'fedi.delete_confirm': 'Delete this reply?', 'fedi.mod_remove_confirm': 'Remove this reply from your thread? It will not come back, not even via thread-filling.', 'fedi.mod_report_confirm': 'Report this reply to its author’s server?', 'fedi.manage_title': 'My fediverse replies', 'fedi.manage_empty': 'You have not sent any replies yet.', 'fedi.goto_post': 'Go to post', 'fedi.edit': 'Edit', 'fedi.save_edit': 'Save', 'fedi.bm_label': 'Interact via my site', 'fedi.bm_help': 'Drag this button to your bookmarks bar. Then click it on any fediverse post (Mastodon, another Klonkt…) to reply, like or boost it via your own site.', 'tl.title': 'News', 'tl.lead': 'Follow accounts on the fediverse and see their posts here.', 'tl.follow_btn': 'Follow', 'tl.following': 'Following', 'tl.unfollow': 'Unfollow', 'tl.autoboost': 'Featured', 'tl.autoboost_follow': 'feature in circle', 'tl.autoboost_hint': 'Their new posts keep showing in your Circle (local, no fediverse boost).', 'tl.moved_title': 'This account has moved', 'tl.moved_lead': 'New posts, follows, likes and replies now happen at', 'tl.moved_hint': 'Reading keeps working here, and replies to your old posts still arrive. Want to come back? Clear the move target under Appearance.', 'tl.move_title': 'Take your following list with you', 'tl.move_hint': 'Moving to another address? Your followers are told automatically, but who YOU follow is not. Take that list with you here. Works to and from Mastodon too.', 'tl.move_export': 'Download list (CSV)', 'tl.move_import_file': 'Choose your downloaded CSV file:', 'tl.move_import_lbl': 'Or paste the list here:', 'tl.move_import': 'Follow everyone', 'tl.pending': 'pending', 'tl.unboost': 'Unboost', 'tl.feed': 'Posts', 'tl.tab_feed': 'News', 'tl.tab_following': 'Following', 'tl.tab_replies': 'Replies', 'tl.tab_followers': 'Followers', 'tl.followers': 'Followers', 'tl.followers_lead': 'Who follows you on the fediverse, with the last successful delivery. Red = never delivered or last attempt failed — a candidate to clean up after a check.', 'tl.empty_followers': 'No followers yet.', 'tl.last_delivery': 'Last delivery', 'tl.never_delivered': 'Never delivered', 'tl.delivery_failed': 'last attempt failed', 'tl.remove_follower': 'Remove', 'tl.folreq_title': 'Follow requests', 'tl.folreq_sub': 'These wait for your yes or no. Until then the requester sees none of your posts.', 'tl.folreq_accept': 'Accept', 'tl.folreq_deny': 'Deny', 'tl.approve_toggle': 'Approve followers first', 'tl.approve_toggle_hint': 'On: follow requests wait here for your yes. Off: anyone may follow right away.', 'tl.remove_confirm': 'Remove this follower? An active account would have to follow you again.', 'tl.tab_connect': 'Connect', 'tl.connect': 'Connect', 'tl.dir_following': 'you follow', 'tl.dir_follower': 'follows you', 'tl.dir_mutual': 'mutual', 'tl.connect_empty': 'No connections yet. Follow someone above to get started.', 'tl.unreachable': 'Unreachable', 'tl.unreachable_lead': 'We could not reach these followers (never delivered or last attempt failed). Clean them up after a manual check.', 'msg.tab': 'Messages', 'msg.title': 'Messages', 'msg.filter_all': 'All', 'msg.filter_msgs': 'Messages', 'msg.filter_conv': 'Conversations', 'msg.filter_act': 'Activity', 'msg.filter_mod': 'Moderation', 'msg.filter_sent': 'Sent', 'msg.search_ph': 'Search messages…', 'msg.no_match': 'Nothing found.', 'msg.poll_done': 'Your poll has ended', 'msg.poll_total': '{n} voters', 'msg.you': 'You', 'msg.sent_reply': 'replied via the fediverse', 'msg.and_more': 'and {n} others', 'msg.liked_many': 'liked your post', 'msg.boosted_many': 'boosted your post', 'msg.private': 'private', 'msg.private_hint': 'Addressed to you only; not shown on the public post page.', 'msg.new': 'New since your last visit', 'msg.empty': 'No messages yet. Replies, mentions and activity show up here.', 'oauth.title': 'Authorize app', 'oauth.wants_access': 'wants to connect to your Klonkt account.', 'oauth.post_as': 'Post as', 'oauth.scope_read': 'Read your posts, replies and notifications', 'oauth.scope_write': 'Post, reply, like and follow on your behalf', 'oauth.allow': 'Allow', 'oauth.deny': 'Deny', 'oauth.foot': 'You can revoke access later. Only authorize apps you trust.', 're.title': 'Write a reply', 're.bold': 'Bold', 're.italic': 'Italic', 're.link': 'Insert link', 're.list': 'Bullet list', 're.quote': 'Quote', 're.lang': 'Language of your reply', 're.attach': 'Add media (image, audio, video)', 're.attach_err': 'Upload failed', 're.to': 'To:', 're.mention_del': 'Stop addressing this person', 'tl.empty_following': 'You do not follow anyone yet.', 'tl.empty': 'Nothing yet — follow someone to see their posts here.', 'tl.view_original': 'View original →', 'tl.open_player': 'Open the player', 'feed.load_more': 'Load more', 'tl.paste_ph': 'Paste a fediverse post URL', 'tl.paste_go': 'Open', 'tl.boosted': 'boosted', 'tl.read_more': 'Read more', 'tl.show_less': 'Show less', 'poll.vote': 'Vote', 'poll.votes': 'votes', 'poll.closed': 'closed', 'poll.open': 'open', 'poll.aria': 'Poll', 'poll.voter_one': 'voter', 'poll.voter_many': 'voters', 'poll.closes': 'closes', 'poll.multiple': 'multiple choice', 'poll.fedi_only': 'Voting happens on the fediverse — follow this site and vote from your own app.', 'poll.voted_title': 'Vote sent', 'poll.voted_done': 'Your vote is on its way to the poll. The results refresh once the author sends the update.',
-    'comments.to_start': 'to start the conversation.',
-    'comments.reply': 'Reply', 'comments.delete': 'Delete', 'comments.cancel': 'Cancel',
-    'comments.delete_confirm': 'Delete this comment?',
-    'comments.reply_to': 'Reply to {name}…',
-    'comments.add_as': 'Add a comment as', 'comments.placeholder': 'Share your thoughts…',
-    'comments.post': 'Post comment', 'comments.login_to_comment': 'Log in to comment',
-    'comments.pending': 'Your comment is awaiting moderation. It will appear once an admin approves it.',
-    'related.title': 'Related posts',
-    'search.placeholder': 'Search posts and tracks…', 'search.button': 'Search',
-    'search.error': 'Couldn’t run that search. Try a simpler term.',
-    'search.results_one': '{n} result for “{q}”', 'search.results_other': '{n} results for “{q}”',
-    'search.section_tracks': 'Tracks', 'search.section_posts': 'Posts',
-    'search.empty': 'Nothing found.', 'search.in_post': 'in post →',
-    'search.section_events': 'Events', 'search.section_pages': 'Pages',
-    'search.page_agenda': 'Agenda', 'search.page_downloads': 'Downloads', 'search.page_links': 'Links', 'search.page_perskit': 'Press kit', 'search.page_archive': 'Archive',
-    'search.suggest_all': 'All results →', 'search.suggest_empty': 'No results', 'search.suggest_typing': 'Type to search…',
-    'like.login_title': 'Log in to like this post', 'like.fedi_title': 'Like this post from your own fediverse account',
-    // === Admin sub-pages ===
-    'aset.title': 'Settings',
-    'aset.back_admin': 'Admin',
-    'aset.mode': 'Mode',
-    'aset.mode_help': 'Determines how this installation works. Switching is safe: nothing is deleted — Solo just hides the multi-site features and shows your primary site.',
-    'aset.solo': 'Solo',
-    'aset.solo_title': 'one site (yours).',
-    'aset.solo_desc': 'No user directory, no site switching.',
-    'aset.premium_badge': 'premium',
-    'aset.circle': 'Circles',
-    'aset.circle_title': 'solo + federation.',
-    'aset.circle_desc': 'A single site of your own that shows the public posts of other Klonkt sites. Asymmetric: you decide who is in your circle.',
-    'aset.save': 'Save',
-    'aset.name': 'Name',
-    'aset.name_ph': 'e.g. Studio Noord',
-    'aset.tagline': 'Tagline',
-    'aset.tagline_ph': 'e.g. Independent music label',
-    'aset.intro': 'Intro',
-    'aset.intro_ph': 'Short intro text below the title.',
-    'aset.hero_image': 'Hero image (URL)',
-    'aset.hero_image_hint': 'optional; background of the hero',
-    'aset.hero_upload': '…or upload an image',
-    'aset.hero_upload_hint': 'jpg/png/webp/gif, max 5 MB; replaces the URL above',
-    'aset.hero_overlay': 'Dark overlay',
-    'aset.hero_overlay_hint': 'darkens the hero so the text stays readable',
-    'aset.preview_overlay': 'Preview (with overlay):',
-    'aset.hero_preview_alt': 'Hero preview',
-    'aset.your_circle': 'Your circle',
-    'aset.your_circle_help': 'Manage which other Klonkt sites you show in your circle, and whether your site may appear in other people\'s circles. Asymmetric: you decide who you follow.',
-    'aset.manage_circle': 'Manage your circle',
-    'aset.premium': 'Premium (Patreon)',
-    'aset.premium_help_1': 'Unlock the premium modules (newsletter, downloads, statistics, press kit, link-in-bio, show agenda) with your',
-    'aset.premium_lifetime': '$16 lifetime',
-    'aset.premium_help_2': 'Patreon support. The app and all updates stay free.',
-    'aset.premium_active': 'Premium active.',
-    'aset.lifetime_support': 'Lifetime support:',
-    'aset.patreon_disconnect': 'Disconnect Patreon',
-    'aset.patreon_no_lifetime': 'Patreon connected, but no $16 lifetime yet',
-    'aset.now': 'now',
-    'aset.patreon_support_again': 'Support the campaign and reconnect.',
-    'aset.patreon_reconnect': 'Reconnect',
-    'aset.patreon_not_connected': 'Not connected yet.',
-    'aset.patreon_connect': 'Connect Patreon',
-    'aset.status_set': 'Status: configured',
-    'aset.not_set_yet': 'Not configured yet.',
-    'aset.newsletter': 'Newsletter',
-    'aset.newsletter_help_1': 'Show a',
-    'aset.newsletter_footer_field': 'sign-up field in the footer',
-    'aset.newsletter_help_2': 'of your site so visitors can subscribe from any page. (The full sign-up page stays at',
-    'aset.newsletter_show_footer': 'Show sign-up field in the footer',
-    'aset.fediverse': 'Fediverse (ActivityPub)',
-    'aset.fediverse_help': 'Let your site join the fediverse: people on Mastodon (or another Klonkt) can follow, like and reply — and those replies show up under your posts. Turn this off and your site does not federate and has no comments (a quiet, standalone blog).',
-    'aset.fediverse_toggle': 'Fediverse on (follow, like, comment)', 'aset.mode': 'Mode', 'aset.mode_help': 'Choose how your site works.', 'aset.mode_solo': 'Solo', 'aset.mode_solo_help': 'A standalone blog — no fediverse, no comments. Quiet and self-contained.', 'aset.mode_cirkels': 'Circles', 'aset.mode_18plus': 'The fediverse is an open network that can also contain adult (18+) content — you must be old enough to take part.', 'aset.mode_18plus_confirm': 'Circles connects your site to the fediverse, an open network that also contains 18+ content. Confirm that you are old enough to enable this.', 'aset.mode_cirkels_help': 'Join the fediverse (ActivityPub): people on Mastodon or another Klonkt can follow, like and reply, and you can follow a circle of sites.',
-    'aset.email_smtp': 'Email (SMTP)',
-    'aset.smtp_help_1': 'Needed to',
-    'aset.smtp_help_newsletter': 'send the newsletter',
-    'aset.smtp_help_2': ',',
-    'aset.smtp_help_notify': 'show-notify',
-    'aset.smtp_help_3': 'emails and to make password reset by email work. Enter your mail provider\'s details (e.g. your hosting mail, a Gmail app password, Brevo, Mailgun…).',
-    'aset.via_env': 'via .env',
-    'aset.smtp_not_set': 'Not configured yet — sending does not work yet.',
-    'aset.smtp_host': 'SMTP host',
-    'aset.smtp_port': 'Port',
-    'aset.smtp_port_hint': '587 (STARTTLS) or 465 (SSL)',
-    'aset.smtp_user': 'Username',
-    'aset.smtp_pass': 'Password',
-    'aset.smtp_pass_set_hint': 'set; leave empty = unchanged',
-    'aset.smtp_pass_ph_set': '•••••••• (set)',
-    'aset.smtp_pass_ph': 'app password',
-    'aset.smtp_from': 'Sender',
-    'aset.smtp_from_hint': 'optional; defaults to username',
-    'aset.smtp_from_ph': 'Your Name <you@yourprovider.com>',
-    'aset.smtp_save': 'Save SMTP',
-    'aset.clear': 'Clear',
-    'aset.test_mail_to': 'Send test mail to',
-    'aset.send_test_mail': 'Send test mail',
-    'asite.back_admin': 'Admin',
-    'asite.title_new': 'New site',
-    'asite.title_edit': 'Appearance',
-    'asite.identity': 'Identity',
-    'asite.slug': 'Slug (URL)',
-    'asite.slug_fixed': '(fixed)',
-    'asite.slug_placeholder': 'yourslug',
-    'asite.field_title': 'Title',
-    'asite.field_title_hint': '— shown in the header and as the display name',
-    'asite.owner': 'Owner',
-    'asite.owner_hint': '— who may manage this Klonkt themselves',
-    'asite.owner_god_suffix': ' (god)',
-    'asite.tagline': 'Tagline',
-    'asite.tagline_hint': '— short one-liner',
-    'asite.bio': 'Bio / description',
-    'asite.bio_hint': '— shown in the profile header and used for SEO',
-    'asite.profile_photo': 'Profile photo',
-    'asite.photo_url_placeholder': '/media/avatars/foo.jpg or https://…',
-    'asite.photo_upload': '📷 Upload',
-    'asite.photo_clear': 'Remove',
-    'asite.language': 'Language (ISO code)',
-    'asite.profile_enabled': 'Show profile header below the navigation',
-    'asite.appearance': 'Appearance',
-    'asite.accent_color': 'Accent color',
-    'asite.theme_default': 'Default theme for new visitors',
-    'asite.theme_auto': 'Auto (follow device preference)',
-    'asite.theme_light': 'Light',
-    'asite.theme_dark': 'Dark',
-    'asite.palette': 'Palette',
-    'asite.behavior': 'Behavior',
-    'asite.is_public': 'Public site (uncheck for a private circle)',
-    'asite.robots_index': 'Allow search engines to index (sitemap.xml is hidden when off)',
-    'asite.require_login_comment': 'Login required to comment',
-    'asite.enable_audio': 'Enable audio player + embeds', 'asite.approve_followers': 'Approve followers first (follow requests wait for your yes on the Connect page)',
-    'asite.links': 'Social / streaming links',
-    'asite.links_hint': 'Shown as brand icons in the profile header. Add as many as you like.',
-    'asite.aliases': 'Fediverse aliases',
-    'asite.move': 'Move (fediverse)',
-    'asite.move_hint': 'Announce to your followers that this account continues elsewhere. The new profile must claim this address as an alias first; followers then move along automatically. An account with guardians cannot move yet.',
-    'asite.move_confirm': 'Are you sure? Your followers will be told this account has moved.',
-    'asite.move_btn': 'Announce move',
-    'asite.moved_to': 'Moved to',
-    'asite.aliases_hint': 'One per line: your old account as @name@server or as an actor URL. Needed to move followers from an old account to this one; the old server checks that this profile claims the old one.',
-    'asite.link_add': '+ Add link',
-    'asite.feed_view': 'Feed display',
-    'asite.feed_default': 'Default view for the homepage',
-    'asite.feed_reader': 'Reader (whole posts, one per screen)',
-    'asite.feed_grid': 'Grid (cards)',
-    'asite.feed_switch': 'Show timeline ↔ grid switch above the feed',
-    'asite.show_search': 'Show search button in the navigation',
-    'asite.show_archive': 'Show archive link in the navigation',
-    'asite.seo': 'SEO & social',
-    'asite.seo_pointer': 'Title template, canonical, share image, verification metas and more now live on their own page:',
-    'asite.seo_link': '🔎 SEO & discoverability',
-    'asite.custom_legend': 'Custom CSS & HTML',
-    'asite.optional': '(optional)',
-    'asite.custom_css': 'Custom CSS (injected as &lt;style&gt; in &lt;head&gt;)',
-    'asite.custom_head': 'Custom &lt;head&gt; HTML (analytics, extra metas)',
-    'asite.custom_foot': 'Custom footer HTML',
-    'asite.submit_create': 'Create site',
-    'asite.submit_save': 'Save changes',
-    'aseo.back': 'Admin',
-    'aseo.title': 'SEO & discoverability',
-    'aseo.tagline_pre': 'Advanced SEO for',
-    'aseo.tagline_post': '— how your site appears in search engines and when shared on social media.',
-    'aseo.index_legend': 'Indexing',
-    'aseo.index_label': 'Allow search engines to index this site',
-    'aseo.index_hint_pre': 'off =',
-    'aseo.index_hint_post': '+ sitemap.xml hidden',
-    'aseo.title_legend': 'Title & description',
-    'aseo.title_template': 'Title template',
-    'aseo.title_template_hint_pre': 'use',
-    'aseo.title_template_hint_and': 'and',
-    'aseo.default_desc': 'Default description',
-    'aseo.default_desc_hint': 'meta description / og:description when a page has none',
-    'aseo.default_desc_ph': 'Short description of your site (max ~160 characters works best)',
-    'aseo.canonical': 'Canonical base URL',
-    'aseo.canonical_hint': 'the production HTTPS URL, prevents duplicate-content penalties',
-    'aseo.author': 'Author',
-    'aseo.author_hint': 'meta author tag',
-    'aseo.author_ph': 'Your name',
-    'aseo.social_legend': 'Sharing on social media',
-    'aseo.og_image': 'Default share image (URL)',
-    'aseo.og_image_hint': 'og:image / Twitter card; ~1200×630px',
-    'aseo.og_theme': 'Share card light or dark',
-    'aseo.og_theme_hint': 'the auto-generated share image',
-    'aseo.og_theme_auto': 'Auto (follows site theme)',
-    'aseo.og_theme_light': 'Light',
-    'aseo.og_theme_dark': 'Dark',
-    'aseo.og_locale': 'Language locale',
-    'aseo.og_locale_hint_pre': 'og:locale, e.g.',
-    'aseo.og_locale_hint_or': 'or',
-    'aseo.twitter': 'Twitter / X handle',
-    'aseo.twitter_hint': 'with @',
-    'aseo.fb_app': 'Facebook App ID',
-    'aseo.fb_app_hint': 'fb:app_id (optional)',
-    'aseo.publisher_legend': 'Publisher (JSON-LD / rich results)',
-    'aseo.type': 'Type',
-    'aseo.type_person': 'Person',
-    'aseo.type_org': 'Organization / company',
-    'aseo.publisher_name': 'Name',
-    'aseo.publisher_name_hint': 'falls back to the site title',
-    'aseo.publisher_url': 'URL',
-    'aseo.publisher_logo': 'Logo (URL)',
-    'aseo.verify_legend': 'Search engine verification',
-    'aseo.verify_google': 'Google site verification',
-    'aseo.verify_bing': 'Bing',
-    'aseo.verify_bing_hint': 'msvalidate.01',
-    'aseo.verify_pinterest': 'Pinterest',
-    'aseo.verify_pinterest_hint': 'p:domain_verify',
-    'aseo.verify_yandex': 'Yandex',
-    'aseo.save': 'Save SEO',
-    'aaud.title': 'Audio tracks',
-    'aaud.tagline_pre': 'Site-level MP3s. Use',
-    'aaud.tagline_post': 'in a post to insert a play button.',
-    'aaud.upload': 'Upload',
-    'aaud.artist': 'Artist',
-    'aaud.album': 'Album',
-    'aaud.applied_all': '(applied to all files)',
-    'aaud.optional': 'Optional',
-    'aaud.cover': 'Cover',
-    'aaud.cover_hint': '(optional, applied to all files — jpg/png/webp/gif, max 5 MB)',
-    'aaud.choose_cover': 'Choose cover',
-    'aaud.no_file': 'No file chosen',
-    'aaud.drag_here': 'Drag audio here',
-    'aaud.or_click': 'or click to choose files',
-    'aaud.start_upload': 'Start upload',
-    'aaud.clear_list': 'Clear list',
-    'aaud.tracks': 'Tracks',
-    'aaud.add_link_track': 'Track without audio',
-    'aaud.add_link_track_title': 'A track without an audio file — title + open-in links only',
-    'aaud.no_tracks': 'No tracks yet. Upload one above.',
-    'aaud.untitled': '(untitled)',
-    'aaud.copy_click': 'Click to copy',
-    'aaud.play': 'Play',
-    'aaud.pause': 'Pause',
-    'aaud.edit': 'Edit',
-    'aaud.delete': 'Delete',
-    'aaud.delete_confirm': 'Delete track?',
-    'aaud.dl_on': 'Download-for-email is ON — click to turn off',
-    'aaud.dl_off': 'Download-for-email is off — click to turn on',
-    'aaud.fedi_on': 'Shared on the fediverse (plays inline everywhere, file downloadable) — click to turn off',
-    'aaud.fedi_off': 'Not shared on the fediverse (web player only, file hidden) — click to share',
-    'aaud.embed_player': 'Embeddable player',
-    'aseo.mb_legend': 'MusicBrainz link',
-    'aseo.mb_linked': 'Linked to',
-    'aseo.mb_unlink': 'Unlink',
-    'aseo.mb_open': 'View on MusicBrainz',
-    'aseo.mb_pick': 'This is me',
-    'aseo.mb_none': 'Nothing found. Not in there yet? You can add yourself on musicbrainz.org — that can only be done there, not from Klonkt.',
-    'aseo.mb_busy': 'Searching…',
-    'aseo.mb_fail': 'MusicBrainz is unreachable right now.',
-    'aseo.mb_hint': 'Link your MusicBrainz artist id to your domain here, with back-way validation from your "social networking" profile page.',
-    'aseo.mb_search_label': 'Search for your name',
-    'aseo.mb_search_hint': 'your artist name, or your MusicBrainz id if you know it',
-    'aseo.mb_placeholder': 'Ozzy Osbourne',
-    'aseo.mb_search': 'Look up',
-    'aseo.mb_verified': 'Mutual: the MusicBrainz page points back at this domain.',
-    'aseo.mb_unverified': 'Still one-sided. Add this domain to your MusicBrainz page under "social networking" and the link is confirmed from both ends.',
-    'aseo.mb_checking': 'Checking the back-way…',
-    'aaud.embed_hint': 'Paste this code on your own website/blog to embed your music with this player:',
-    'aaud.preview_player': 'Open player preview',
-    'aaud.st_queued': 'Waiting',
-    'aaud.st_uploading': 'Uploading…',
-    'aaud.st_transcoding': 'Converting…',
-    'aaud.st_done': 'Done',
-    'aaud.st_error': 'Error',
-    'aaud.err_unexpected': 'Unexpected server response',
-    'aaud.failed': 'Failed',
-    'aaud.copied': 'copied',
-    'aaud.new_track': 'New track',
-    'aaud.create_failed': 'Could not create track',
-    'aaud.editor_not_loaded': 'Track editor not loaded',
-    'aaud.change_failed': 'Could not change',
-    'astat.title': 'Statistics',
-    'astat.intro': 'Measured cookie-free — no tracking cookies, no consent banner. Visitors are counted per day via a daily-rotating, anonymous hash (IP/browser are not stored). Your own admin visits and known bots/crawlers are not counted.',
-    'astat.your_ip': 'Your IP', 'astat.ip_counted': 'is being counted.', 'astat.ip_not_counted': 'is NOT counted.', 'astat.ip_exclude': "Don't count my visits", 'astat.ip_count': 'Count my visits',
-    'astat.visitor_days': 'Visitor-days ({n}d)',
-    'astat.pageviews_days': 'Views ({n}d)',
-    'astat.plays_total': 'Plays (total)',
-    'astat.postviews_total': 'Post views (total)',
-    'astat.alltime_pre': 'All-time:',
-    'astat.alltime_mid': 'views',
-    'astat.alltime_post': 'visitor-days.',
-    'astat.help_summary': 'What do these numbers mean?',
-    'astat.help_vd_term': 'Visitor-days',
-    'astat.help_vd_a': 'the number of unique visitors',
-    'astat.help_vd_em': 'per day, added together',
-    'astat.help_vd_b': '. One person who visits on 5 days = 5 visitor-days. Without cookies it is impossible to count across days, so this is not a count of unique people — the real number of people is (often much) lower.',
-    'astat.help_pv_term': 'Views',
-    'astat.help_pv': 'how often the home/feed or a post has been loaded (including clicks within the site). Other pages (agenda, downloads, links) are not counted here.',
-    'astat.help_plays_term': 'Plays',
-    'astat.help_plays': 'total number of times a track has been started.',
-    'astat.help_postviews_term': 'Post views',
-    'astat.help_postviews': 'total across all posts combined.',
-    'astat.help_footer': 'Admin visits and known bots/crawlers are skipped. The raw IP is never stored. Good for trends; take absolute numbers with a grain of salt.',
-    'astat.period': 'Period:',
-    'astat.last_n_days': 'Last {n} days',
-    'astat.lg_visitor_days': 'Visitor-days',
-    'astat.lg_pageviews': 'Views',
-    'astat.bar_title': '{day} — {pv} views, {vd} visitor-days',
-    'astat.top_posts': 'Most popular posts',
-    'astat.no_views': 'No views yet.',
-    'astat.most_played': 'Most listened',
-    'astat.no_plays': 'No plays yet.',
-    'astat.sources': 'Sources (where visitors come from)',
-    'astat.linkbio_clicks': 'Link-in-bio clicks',
-    'apl.title': 'Playlists',
-    'apl.tagline_pre': 'Canonical playlists. Edit a playlist here and the changes carry through to every post that uses it via',
-    'apl.tagline_post': '.',
-    'apl.new_playlist': 'New playlist',
-    'apl.none': 'No playlists yet.',
-    'apl.none_sub': 'Create one with the button above, or via the 📃 button in the post editor.',
-    'apl.pill_playlist': 'playlist',
-    'apl.pill_album': 'album', 'apl.pill_mixtape': 'mixtape',
-    'apl.track': 'track',
-    'apl.tracks': 'tracks',
-    'apl.copy_click': 'Click to copy',
-    'apl.edit': 'Edit',
-    'apl.delete': 'Delete',
-    'apl.copied': 'copied',
-    'apl.delete_confirm': 'Delete playlist "{title}"? Posts that embed this playlist will now show a placeholder.',
-    'apl.delete_failed': 'Delete failed',
-    'ausr.back': 'Admin',
-    'ausr.title': 'Users',
-    'ausr.tagline_a': 'Manage users, roles, and deletions. The',
-    'ausr.tagline_b': 'role = view everything (including Admin), change nothing — handy for demos.',
-    'ausr.empty': 'No users.',
-    'ausr.you': 'you',
-    'ausr.t_sites': 'Sites',
-    'ausr.t_posts': 'Posts',
-    'ausr.t_joined': 'Registered on',
-    'ausr.l_sites': 'sites',
-    'ausr.l_posts': 'posts',
-    'ausr.l_joined': 'joined',
-    'ausr.new_klonkt': 'New Klonkt for this user',
-    'ausr.new_klonkt_for': 'New Klonkt for {name}',
-    'ausr.role_label': 'Role',
-    'ausr.role_kijker': 'viewer',
-    'ausr.role_member': 'member',
-    'ausr.role_admin': 'admin',
-    'ausr.role_god': 'god',
-    'ausr.delete': 'Delete',
-    'ausr.del_warn': 'This also deletes their site + {n} post(s).',
-    'ausr.del_confirm': 'Delete user {name}?',
-    'ausr.del_undo': 'This cannot be undone.',
-    'asit2.back': 'Admin',
-    'asit2.title': 'Sites',
-    'asit2.tagline': 'Manage all sites on this installation.',
-    'asit2.new_site': 'New site',
-    'asit2.empty': 'No sites yet.',
-    'asit2.empty_sub': 'Create one using the button above.',
-    'asit2.pill_primary': 'primary',
-    'asit2.pill_primary_title': 'Main/label site of this installation',
-    'asit2.pill_public': 'public',
-    'asit2.pill_public_title': 'Publicly visible',
-    'asit2.pill_private': 'private',
-    'asit2.pill_private_title': 'Not public',
-    'asit2.pill_noindex': 'noindex',
-    'asit2.pill_noindex_title': 'Not indexed by search engines',
-    'asit2.by': 'by',
-    'asit2.t_posts': 'Number of posts',
-    'asit2.l_posts': 'posts',
-    'asit2.t_created': 'Created on',
-    'asit2.l_created': 'created',
-    'asit2.make_primary': 'Make primary',
-    'asit2.make_primary_title': 'Make primary/main site',
-    'asit2.make_primary_confirm': 'Set this site as the primary/main site?',
-    'asit2.edit': 'Edit',
-    'asit2.delete': 'Delete',
-    'asit2.delete_confirm': 'Delete site? Only works if there are no posts.',
-    'acom.back': 'Admin',
-    'acom.title': 'Comment moderation',
-    'acom.mode_for_site': 'Mode for this site:',
-    'acom.mode_trust_a': 'comments are auto-approved. Switch to',
-    'acom.mode_moderate_word': 'moderate',
-    'acom.site_settings': 'site settings',
-    'acom.mode_trust_b': 'to queue them.',
-    'acom.mode_moderate_hint': 'new comments require approval before showing up on posts.',
-    'acom.pending': 'Pending ({n})',
-    'acom.nothing_waiting': 'Nothing waiting.',
-    'acom.reply': 'reply',
-    'acom.on': 'on',
-    'acom.approve': 'Approve',
-    'acom.reject': 'Reject',
-    'acom.recent': 'Recent decisions',
-    'acom.nothing_yet': 'Nothing yet.',
-    'acir.title': 'Circles',
-    'acir.back_settings': 'Settings',
-    'acir.circles': 'Circles',
-    'acir.settings': 'Settings',
-    'acir.mode_off_1': 'The mode is not set to',
-    'acir.mode_off_2': '. Turn it on under',
-    'acir.mode_off_3': 'to show your circle feed at',
-    'acir.mode_off_4': '. You can already set up sources below.',
-    'acir.visibility_title': 'My visibility',
-    'acir.all_public': 'already-public',
-    'acir.visibility_help_1': 'Taking part in circles? This makes your',
-    'acir.visibility_help_2': 'posts fetchable by other Klonkt sites through a signed feed',
-    'acir.visibility_help_3': 'It is an opt-in choice, not a privacy lock: your posts stay public on your site anyway, even when this is off. To shield something, mark that post as non-public.',
-    'acir.show_in_circles': 'Show my site in other people\'s circles',
-    'acir.save': 'Save',
-    'acir.add_title': 'Add a Klonkt site',
-    'acir.add_help': 'Paste the base URL of another Klonkt site. Asymmetric: you show them, regardless of whether they show you.',
-    'acir.url': 'URL',
-    'acir.label': 'Label',
-    'acir.optional': 'optional',
-    'acir.name_auto': 'The name is taken automatically from the site — only the URL is needed.',
-    'acir.label_ph': 'e.g. Joost Klein',
-    'acir.add': 'Add',
-    'acir.in_circle': 'In my circle ({n})',
-    'acir.no_sources': 'No sources yet. Add one above.',
-    'acir.sync_all': 'Sync all now',
-    'acir.st_active': 'active',
-    'acir.posts': 'posts',
-    'acir.last': 'last',
-    'acir.st_mismatch': 'version mismatch',
-    'acir.mismatch_reason': 'This site runs a different Klonkt protocol version — an update is needed to federate.',
-    'acir.st_error': 'error',
-    'acir.st_paused': 'paused',
-    'acir.refresh': 'Refresh',
-    'acir.remove': 'Remove',
-    'acir.remove_confirm': 'Remove from your circle?',
-    'ashow.back_admin': 'Admin',
-    'ashow.title': 'Calendar',
-    'ashow.show_toggle': 'Show the calendar on the site',
-    'ashow.show_toggle_hint': '(Calendar button in the bar + the calendar page)',
-    'ashow.save': 'Save',
-    'ashow.off': 'off',
-    'ashow.off_notice_1': 'The calendar is currently',
-    'ashow.off_notice_2': '— visitors see no Calendar button and the calendar page is unreachable. Turn it on to show your events.',
-    'ashow.subscribers': 'subscriber(s) for event announcements.',
-    'ashow.smtp_warn': '⚠ SMTP not configured — events are saved, but notify emails can only be sent once you fill in SMTP.',
-    'ashow.f_date': 'Date',
-    'ashow.f_time': 'Time (optional)',
-    'ashow.f_city': 'City',
-    'ashow.f_country': 'Country (optional)',
-    'ashow.f_venue': 'Venue/hall (optional)',
-    'ashow.f_ticket': 'Ticket URL (optional)',
-    'ashow.f_notes': 'Note (optional)',
-    'ashow.f_notes_ph': 'Support: ...',
-    'ashow.notify_label': 'Notify subscribers by email',
-    'ashow.smtp_required': '(SMTP required)',
-    'ashow.add_event': '+ Add event',
-    'ashow.del_confirm': 'Delete event??',
-    'ashow.empty': 'No events yet.',
-    'anews.title': 'Newsletter',
-    'anews.confirmed': 'confirmed',
-    'anews.pending': 'pending',
-    'anews.unsub': 'unsubscribed',
-    'anews.smtp_warn_1': '⚠ SMTP is not configured yet. Sign-ups are still collected, but sending is only possible once you fill in your SMTP details',
-    'anews.smtp_warn_2': 'in',
-    'anews.share': 'Sign-up link to share:',
-    'anews.subject': 'Subject',
-    'anews.subject_ph': 'New single out!',
-    'anews.body': 'Message',
-    'anews.body_ph': 'Write your update…',
-    'anews.send_confirm': 'Send newsletter to {n} confirmed subscriber(s)?',
-    'anews.send_btn': 'Send to {n} subscriber(s)',
-    'anews.sent_heading': 'Sent',
-    'anews.recipients': 'recipient(s)',
-    'aupd.title': 'Updates',
-    'aupd.changes_heading': 'Recent changes',
-    'aupd.back_admin': 'Admin',
-    'aupd.version_heading': 'Version of this Klonkt',
-    'aupd.app_version': 'App version',
-    'aupd.current': 'Current',
-    'aupd.current_unknown': 'unknown (not yet updated via the update button)',
-    'aupd.latest': 'Latest',
-    'aupd.latest_failed': 'could not fetch the latest version',
-    'aupd.no_source': 'No update source reachable',
-    'aupd.uptodate': 'Up to date',
-    'aupd.update_available': 'Update available',
-    'aupd.behind_one': '{n} commit behind',
-    'aupd.behind_many': '{n} commits behind',
-    'aupd.run_confirm': 'The site will be brought to the latest version and restart briefly. Continue?',
-    'aupd.redeploy': 'Redeploy',
-    'aupd.update_now': 'Update now',
-    'aupd.help': 'Updating fetches the latest code and restarts this site briefly (~10s). Take your time — nothing is lost (your posts, settings and circle stay intact).',
-    'aupd.manual_hint': 'Update from GitHub by running this on your server:',
-    'aepk.title': 'Edit press kit',
-    'aepk.saved': 'Press kit saved',
-    'aepk.back_admin': 'Admin',
-    'aepk.view_epk': 'View press kit',
-    'aepk.text_heading': 'Text',
-    'aepk.text_help': 'The press kit (/pers) shows your site name + photo, this bio and contact, plus your most-played tracks and recent posts automatically. Leave the bio empty to use the site tagline; leave contact empty to show nothing (your login email is never shown automatically).',
-    'aepk.bio_label': 'Press bio',
-    'aepk.bio_ph': 'Short description of you/the project for press & bookers.',
-    'aepk.contact_label': 'Press contact',
-    'aepk.contact_ph': 'e.g. press@yourdomain.com or a booking link',
-    'aepk.tracks_label': 'Tracks on the press kit',
-    'aepk.tracks_hint': '(pick up to {n}; leave empty for the top {n} most-played automatically)',
-    'aepk.no_tracks': 'No tracks yet — add audio first under Admin → Audio.',
-    'aepk.untitled': '(untitled)',
-    'aepk.save': 'Save',
-    'ahelp.back': 'Admin',
-    'ahelp.title': 'Help guide',
-    'ahelp.intro': 'An explanation of every feature. Type below to search by topic or instruction.',
-    'ahelp.search_placeholder': 'Search… (e.g. \'agenda\', \'photo\', \'circle\', \'downloads\')',
-    'ahelp.search_aria': 'Search the help guide',
-    'ahelp.premium': 'premium',
-    'ahelp.empty': 'No topics found for your search.',
-    'ahelp.s_newpost_h': 'Writing a new post',
-    'ahelp.s_newpost_b': 'Admin → <strong>New post</strong>. At the top you pick the <strong>type</strong> (Post · Photo · Video · Audio) — that decides the inputs below. Give it a title and write your content. At the bottom you choose the status: <em>draft</em> (not visible) or <em>published</em>. Drafts appear at the top of your Admin overview so you can find them again.',
-    'ahelp.s_excerpt_h': 'Summary & Circle Preview',
-    'ahelp.s_excerpt_b': 'The <strong>Summary & Circle Preview</strong> field (the excerpt) is the short preview text shown below a post in listings, and the summary that other sites display when they pick up your post via a <strong>Circle</strong>. Leave it empty and the start of the post is used automatically.',
-    'ahelp.s_pin_h': 'Pinning posts / ordering',
-    'ahelp.s_pin_b': 'In the post editor you can <strong>pin</strong> a post with a rank (1 = top). Pinned posts appear first in the timeline/grid, in order of their rank. An empty rank or 0 = not pinned.',
-    'ahelp.s_schedule_h': 'Scheduling & friends only',
-    'ahelp.s_schedule_b': 'In the editor you can set a <strong>publish date</strong> in the future; the post then appears automatically at that moment. With <strong>Friends only</strong>, visitors who are not logged in see only a teaser + login invitation; logged-in friends see everything.',
-    'ahelp.s_images_h': 'Images in posts',
-    'ahelp.s_images_b': 'Images in the text and the cover image are always shown in <strong>full</strong> at full width (not cropped), at their natural height.',
-    'ahelp.s_audio_h': 'Adding audio & tracks',
-    'ahelp.s_audio_b': 'Admin → <strong>Audio</strong>. Upload a file or add a <em>link-only</em> track (no upload, just "open in" links). For each track you fill in the title, artist, cover and, optionally, album/position. In a post you show a track with the shortcode <code>[[track:id]]</code>, an album with <code>[[album:Name]]</code>, a playlist with <code>[[playlist:id]]</code>. <strong>Faster:</strong> in a post, pick type <em>Audio</em> at the top and drop the file straight in — it gets converted and added to the post.',
-    'ahelp.s_credit_h': 'Credit, licence & "open in"',
-    'ahelp.s_credit_b': 'For each track you can set an <strong>owner/credit</strong> (with a © button) and a <strong>licence</strong> — these are also written into the mp3 metadata. The <strong>open-in</strong> fields (Spotify / YouTube / SoundCloud) add buttons to open the track on those platforms.',
-    'ahelp.s_downloads_h': 'Downloads',
-    'ahelp.s_downloads_b': 'Mark a track as <strong>downloadable</strong> in Admin → Audio (⬇ button). Visitors find them on the <strong>/downloads</strong> page and leave their email to receive the file (it goes onto your mailing list). Want downloads prominent in the feed? Create a regular post with the slug <code>downloads</code> and pin it.',
-    'ahelp.s_albums_h': 'Albums & playlists',
-    'ahelp.s_albums_b': 'Give tracks the same <strong>album</strong> + a <strong>position</strong> to form an album. You create playlists in Admin → <strong>Playlists</strong>. You show both in a post with <code>[[album:Name]]</code> or <code>[[playlist:id]]</code>.',
-    'ahelp.s_agenda_h': 'Agenda / events',
-    'ahelp.s_agenda_b': 'Admin → <strong>Agenda</strong>. Turn on <strong>"Show agenda on the site"</strong> at the top — then the Agenda button appears in the bar and the agenda page becomes reachable. Add events (date, town, venue, tickets). Visitors can sign up (separately from the newsletter) for a heads-up about a new event.',
-    'ahelp.s_presskit_h': 'Press kit',
-    'ahelp.s_presskit_b': 'A shareable press page at <strong>/pers</strong>. Edit it via the <strong>✎ Edit</strong> button on that page itself (only you see it). Set a short press bio + contact, and choose <strong>up to 5 tracks</strong> to be shown (or leave empty = automatically the top 5 most-played).',
-    'ahelp.s_circles_h': 'Circles (federation)',
-    'ahelp.s_circles_b': 'A <strong>Circle</strong> is your own curated feed. Open <strong>Fediverse → Following</strong>, follow accounts and <strong>feature them</strong> (✨). Featured accounts and posts you <strong>boost</strong> (🔁) show up in your <strong>/cirkel</strong> feed — a boosted post gets a Boost badge. Boosting a post from someone you do not follow adds it too. This is local: nothing is sent to the fediverse automatically — boosting to your own followers is always a deliberate per-post action.',
-    'ahelp.s_stats_h': 'Statistics',
-    'ahelp.s_stats_b': 'Admin → <strong>Statistics</strong>. Measured cookie-free. <strong>Visitor-days</strong> = unique visitors per day, added up (not a number of people). <strong>Views</strong> = home/feed and post loads. Admin visits and bots do not count. Good for trends; take absolute numbers with a grain of salt.',
-    'ahelp.s_newsletter_h': 'Newsletter',
-    'ahelp.s_newsletter_b': 'Admin → <strong>Newsletter</strong>: compose a message and send it to your confirmed subscribers. Visitors sign up via the footer or <strong>/nieuwsbrief</strong>. Sending requires that email (SMTP) is configured.',
-    'ahelp.s_linkbio_h': 'Link-in-bio',
-    'ahelp.s_linkbio_b': 'A Linktree-style page at <strong>/links</strong> with your profile links. You can see the clicks per link in Statistics.',
-    'ahelp.s_embed_h': 'Embeddable player',
-    'ahelp.s_embed_b': 'Admin → Audio shows a copyable <code>&lt;iframe&gt;</code> code (<strong>/embed</strong>) that lets you embed your player on another website.',
-    'ahelp.s_appearance_h': 'Appearance (theme, photo, accent)',
-    'ahelp.s_appearance_b': 'Admin → <strong>Appearance</strong>: set your site name, tagline, profile photo, accent colour and colour palette, and the default feed view (Timeline or Grid).',
-    'ahelp.s_tenancy_h': 'Solo / Circle mode',
-    'ahelp.s_tenancy_b': 'At the top of <strong>Admin → Settings</strong> you choose the mode. <em>Solo</em> = a standalone blog: no fediverse, no comments. <em>Circles</em> = your site joins the fediverse (ActivityPub): people can follow you and reply, and you get the Fediverse section + your Circle feed. Switching is safe — nothing gets deleted.',
-    'ahelp.s_fedi_h': 'Fediverse (ActivityPub)',
-    'ahelp.s_fedi_b': 'In <strong>Circles</strong> mode your site joins the fediverse (Mastodon etc.). Open the <strong>Fediverse</strong> section via the globe in the menu bar (or the bell for notifications). Five tabs: <strong>News</strong> (posts from the accounts you follow — ⭐ like and 🔁 boost here, click again to undo), <strong>Following</strong> (follow accounts by @handle or profile URL, and feature ✨ them for your Circle), <strong>Replies</strong> (your sent replies + the drag-to-bookmarks-bar <em>interaction bookmarklet</em> to reply from any fediverse post), <strong>Notifications</strong> (new followers, likes, boosts and replies to your posts) and <strong>Blocking</strong> (block an account or a whole domain). Under each post, "From the fediverse" shows incoming replies, likes and boosts; as the owner you can reply, like or boost them right there. Visitors use the "Interact via the fediverse" button to respond from their own account. Click your profile photo for a profile summary; visitors also find a "Follow via the fediverse" button there. Your posts are delivered to your followers automatically.',
-    'ahelp.s_premium_h': 'Premium / Patreon',
-    'ahelp.s_premium_b': 'You unlock premium features (Statistics, Agenda, Downloads, Press kit, Newsletter, Link-in-bio, Embed) in Admin → Settings by connecting Patreon ($16 lifetime). Updates and the core app always stay free.',
-    'ahelp.s_password_h': 'Forgotten password / resetting',
-    'ahelp.s_password_b': 'Resetting is done with the <strong>command-line scripts</strong> on the server. Run <code>npm run reset-admin</code> from the project folder: with no argument it resets the god user and prints the new password. A specific user: <code>npm run reset-admin -- &lt;user|email&gt;</code>. Choose a password yourself (at least 8 characters): <code>npm run reset-admin -- &lt;user|email&gt; &lt;password&gt;</code>. Then log in at <strong>/auth/login</strong>. The email reset link at <strong>/auth/reset-request</strong> only works if email (SMTP) is configured; the command line always works.',
-    'ahelp.s_updates_h': 'Updates',
-    'ahelp.s_updates_b': 'Admin → <strong>Updates</strong> (god only) shows the current version and whether a newer one is available. With "Update now" you fetch the latest version.',
-    'pedit.title_new': 'New post',
-    'pedit.title_edit': 'Edit post',
-    'pedit.f_title': 'Title',
-    'pedit.f_slug': 'Slug (URL)',
-    'pedit.slug_placeholder': 'auto from title if empty',
-    'pedit.f_tags': 'Tags',
-    'pedit.tags_hint': '(comma-separated)',
-    'pedit.f_excerpt': 'Summary & Circle Preview',
-    'pedit.excerpt_hint': 'Also used as the summary in <strong>Circles</strong> (other sites that show your post). Leave empty = start of the post.',
-    'pedit.s_cover': 'Cover',
-    'pedit.f_cover_url': 'Cover URL',
-    'pedit.cover_url_placeholder': '/media/…  or https://…  (or upload with the button)',
-    'pedit.f_cover_alt': 'Alt text (description)',
-    'pedit.cover_alt_placeholder': 'Describe the image for screen readers',
-    'pedit.f_language': 'Language',
-    'pedit.language_hint': 'for the fediverse language filter',
-    'pedit.cover_upload_btn': 'Upload new cover',
-    'pedit.s_content': 'Content',
-    'pedit.content_hint': 'Drag an image to insert · select text to format',
-    'pedit.tb_done': 'Done',
-    'pedit.tb_done_title': 'Done editing',
-    'pedit.tb_bold': 'Bold',
-    'pedit.tb_bold_title': 'Bold (Ctrl+B)',
-    'pedit.tb_italic': 'Italic',
-    'pedit.tb_italic_title': 'Italic (Ctrl+I)',
-    'pedit.tb_underline': 'Underline',
-    'pedit.tb_h2': 'Heading',
-    'pedit.tb_h3': 'Subheading',
-    'pedit.tb_p': 'Paragraph',
-    'pedit.tb_ul': 'List',
-    'pedit.tb_ol': 'Numbered list',
-    'pedit.tb_quote': 'Quote',
-    'pedit.tb_link': 'Link',
-    'pedit.tb_link_title': 'Link (Ctrl+K)',
-    'pedit.tb_code': 'Code',
-    'pedit.tb_code_title': 'Code (inline)',
-    'pedit.tb_image': 'Image',
-    'pedit.tb_image_title': 'Insert image',
-    'pedit.tb_track': 'Track',
-    'pedit.tb_track_title': 'Insert track',
-    'pedit.tb_playlist': 'Playlist',
-    'pedit.tb_playlist_title': 'Insert playlist',
-    'pedit.tb_embed': 'Embed media',
-    'pedit.tb_embed_title': 'Embed (YouTube, Spotify, SoundCloud, Vimeo…)',
-    'pedit.tb_clear': 'Clear formatting',
-    'pedit.tb_fullscreen': 'Full screen',
-    'pedit.editor_aria': 'Content',
-    'pedit.editor_placeholder': 'Start writing…',
-    'pedit.tap_to_edit': 'Tap to edit',
-    'pedit.tap_to_write': 'Tap to write…',
-    'pedit.chars': 'characters',
-    'pedit.s_publication': 'Publication',
-    'pedit.f_status': 'Status',
-    'pedit.status_published': 'Published',
-    'pedit.status_draft': 'Draft',
-    'pedit.status_archived': 'Archived',
-    'pedit.f_type': 'Type',
-    'pedit.type_post': 'Post',
-    'pedit.type_foto': 'Photo',
-    'pedit.type_video': 'Video',
-    'pedit.type_audio': 'Audio',
-    'pedit.type_album': 'Album',
-    'pedit.type_playlist': 'Playlist', 'pedit.type_mixtape': 'Mixtape',
-    'pedit.s_type': 'What kind of post?',
-    'pedit.audio_up_drop': 'Drop audio here or click to choose',
-    'pedit.audio_up_hint': 'mp3, m4a, ogg, flac, wav — converted automatically and added straight to your post. Edit details later via the track.',
-    'pedit.audio_up_busy': 'Uploading…',
-    'pedit.audio_up_done': 'Added to your post',
-    'pedit.audio_up_fail': 'Failed',
-    'pedit.video_up_title': 'Add a video',
-    'pedit.video_up_ph': 'Paste a video URL (YouTube, Vimeo…)',
-    'pedit.video_up_btn': 'Insert',
-    'pedit.foto_up_hint': 'Set your photo as the cover below, or add photos in the text with the image button in the toolbar.',
-    'pedit.pin_label': 'Pin to top',
-    'pedit.pin_up': 'Move up',
-    'pedit.pin_down': 'Move down',
-    'pedit.pin_top': 'at the top',
-    'pedit.pin_nth_suffix': 'th from top',
-    'pedit.noindex_label': 'noindex (hide from search engines)', 'pedit.nsfw_label': 'NSFW / sensitive content', 'pedit.fedi_audio_label': 'Share audio openly on the fediverse (plays inline in apps; file downloadable)', 'pedit.fedi_audio_oneway': 'Note: opening is permanent', 'pedit.fedi_audio_locked': 'This audio has been shared openly on the fediverse. That is permanent — the file is already out there.', 'pedit.nsfw_cw_ph': 'Warning text (optional, default: Sensitive content)', 'post.nsfw_warning': 'Sensitive content', 'post.nsfw_show': 'Show', 'post.share': 'Share', 'post.share_copied': 'Link copied ✓', 'pedit.poll_label': 'Add a poll', 'pedit.poll_locked': 'Votes are in — the options can no longer change.', 'pedit.poll_option_ph': 'Option', 'pedit.poll_add': 'Add option', 'pedit.poll_remove': 'Remove option', 'pedit.poll_multiple': 'Allow multiple choices', 'pedit.poll_duration': 'Duration', 'pedit.poll_dur_5m': '5 minutes', 'pedit.poll_dur_30m': '30 minutes', 'pedit.poll_dur_1h': '1 hour', 'pedit.poll_dur_6h': '6 hours', 'pedit.poll_dur_12h': '12 hours', 'pedit.poll_dur_1d': '1 day', 'pedit.poll_dur_3d': '3 days', 'pedit.poll_dur_7d': '7 days',
-    'pedit.fan_only_label': 'Friends only',
-    'pedit.schedule_label': 'Schedule publication',
-    'pedit.schedule_hint': 'When off, your post goes live immediately. On = pick below when it appears.',
-    'pedit.publish_at_label': 'Date & time',
-    'pedit.scheduled_for': 'Scheduled for {d}',
-    'pedit.scheduled_prefix': 'Scheduled for',
-    'pedit.cancel': 'Cancel',
-    'pedit.publish': 'Publish',
-    'pedit.save': 'Save',
-    'pedit.js_link_prompt': 'Link URL (https://… or /path)',
-    'pedit.js_uploading': 'Uploading…',
-    'pedit.js_uploaded': 'Uploaded',
-    'pedit.js_inserted': 'Inserted',
-    'pedit.js_failed': 'Failed',
-    'pedit.js_embed_prompt': 'Paste a media URL to embed (YouTube, Spotify, SoundCloud, Vimeo, Apple Music, Bandcamp):',
-    'pedit.js_embed_invalid': 'Enter a full URL (https://…).',
-    'pedit.js_no_tracks_found': 'No tracks found for',
-    'pedit.js_no_tracks_yet': 'No tracks yet. Upload via Admin then Audio.',
-    'pedit.js_tracks_loading': 'Loading tracks…',
-    'pedit.js_tracks_load_fail': 'Could not load tracks',
-    'pedit.js_playlist_editor_missing': 'Playlist editor not loaded',
-    'pedit.js_playlist_existing': 'Existing playlists:',
-    'pedit.js_playlist_choose': 'Choose a number to insert, empty = create new:',
-    'pedit.chip_track': 'Track',
-    'pedit.chip_album': 'Album:',
-    'pedit.chip_playlist': 'Playlist:',
-    'pedit.tp_title': 'Insert track',
-    'pedit.tp_close': 'Close',
-    'pedit.tp_search_placeholder': 'Search by title or artist…',
-    'pedit.tp_list_aria': 'Tracks',
-    'imed.title': 'Edit image',
-    'imed.rotate_left': 'Rotate left', 'imed.rotate_right': 'Rotate right',
-    'imed.flip_h': 'Flip horizontally', 'imed.flip_v': 'Flip vertically',
-    'imed.zoom_in': 'Zoom in', 'imed.zoom_out': 'Zoom out', 'imed.reset': 'Reset',
-    'imed.cancel': 'Cancel', 'imed.apply': 'Apply',
-    'acct.back_home': 'Back to home',
-    'acct.title': 'Account',
-    'acct.subtitle': 'Profile and avatar.', 'acct.oauth_apps': 'Connected apps', 'acct.oauth_hint': 'Apps you granted access to your account via OAuth. Revoke anything you no longer trust or use.', 'acct.oauth_none': 'No apps connected yet.', 'acct.oauth_unknown_app': 'Unknown app', 'acct.oauth_last_used': 'last used', 'acct.oauth_never': 'never', 'acct.oauth_revoke': 'Revoke', 'acct.oauth_revoked': 'App access revoked.', 'acct.oauth_revoke_none': 'That access no longer existed.',
-    'acct.viewer_mode': 'Viewer mode',
-    'acct.viewer_note': 'This is a demo account. You can view everything, but change nothing — not even your photo or bio.',
-    'acct.profile': 'Profile',
-    'acct.avatar_change': 'Click to change your photo',
-    'acct.member_since': 'Member since', 'acct.photo_site_hint': 'Your profile photo is set in your <a href="/admin">site settings</a> — one photo, used everywhere.',
-    'acct.avatar_remove': 'Remove photo',
-    'acct.username': 'Username',
-    'acct.email': 'Email address',
-    'acct.email_ph': 'you@email.com',
-    'acct.bio': 'Bio',
-    'acct.bio_ph': 'A short line about yourself',
-    'acct.bio_empty': 'No bio',
-    'acct.save': 'Save',
-    'acct.site': 'Site',
-    'acct.site_name': 'Site name',
-    'acct.site_name_hint': '— shown in your site\'s header',
-    'acct.tagline': 'Tagline',
-    'acct.tagline_hint': '— short one-liner (optional)',
-    'acct.site_save': 'Save site',
-    'acct.password_change': 'Change password',
-    'acct.password_current': 'Current password',
-    'acct.password_new': 'New password',
-    'acct.password_min': '(min 8 characters)',
-    'acct.password_confirm': 'Confirm new password',
-    'acct.login': 'Sign in',
-    'acct.login_google_only': 'This account has no password ({email}). Use "Forgot password" to set one.',
-    'news.this_artist': 'this artist',
-    'news.form_title': 'Stay in the loop',
-    'news.form_sub_before': 'Subscribe to the newsletter from ',
-    'news.form_sub_after': ' — new music, shows and updates, straight to your inbox. You can unsubscribe anytime with one click.',
-    'news.email_ph': 'you@email.com',
-    'news.subscribe': 'Subscribe',
-    'news.check_title': 'Almost done ✉',
-    'news.check_sub_before': 'We\'ve sent a confirmation email to ',
-    'news.check_sub_after': '. Click the link in that email to confirm your subscription.',
-    'news.your_address': 'your address',
-    'news.done_title': 'You\'re subscribed ✓',
-    'news.done_sub_before': 'Thanks — you\'re on the list of ',
-    'news.done_sub_after': '.',
-    'news.confirmed_title': 'Subscription confirmed ✓',
-    'news.confirmed_sub_before': 'Great! You\'ll now receive the newsletter from ',
-    'news.confirmed_sub_after': '.',
-    'news.unsubbed_title': 'Unsubscribed',
-    'news.unsubbed_sub': 'You\'ve been unsubscribed. You won\'t receive any more newsletters. Changed your mind? You can always subscribe again.',
-    'news.invalid_title': 'Invalid email address',
-    'news.invalid_sub': 'Please check your address and try again.',
-    'news.back': '← Back',
-    'news.smtperror_title': 'That didn\'t work',
-    'news.smtperror_sub': 'The confirmation email couldn\'t be sent. Please try again later.',
-    'news.badtoken_title': 'Link invalid or expired',
-    'news.badtoken_sub': 'This link no longer works. Feel free to subscribe again.',
-    'news.to_subscribe': 'To subscribe',
-    'news.error_title': 'Something went wrong',
-    'news.error_sub': 'Please try again later.',
-    'dl.ready_title': 'Thanks! ⬇',
-    'dl.ready_sub': 'Your download should start now. Nothing happening?',
-    'dl.manual': 'Start download manually',
-    'dl.download_btn': 'Download',
-    'dl.capture_sub': 'Leave your email address and you\'ll get the file. You\'ll also be added to the newsletter list — you can unsubscribe anytime.',
-    'dl.email_ph': 'you@email.com',
-    'epk.kicker': 'Press kit',
-    'epk.edit': 'Edit',
-    'epk.contact_booking': 'Contact / booking',
-    'epk.view_site': 'View the site →',
-    'epk.press_photo': 'Press photo',
-    'epk.most_played': 'Most played',
-    'epk.recent': 'Recent',
-    'fgate.title': 'Friends only',
-    'fgate.sub': 'This post is for people who follow this site.',
-    'owa.title': 'Sign in to another site', 'owa.sub': 'This site is asking your server to confirm who you are. If you continue, it learns your address.', 'owa.as': 'You are signing in as', 'owa.choose': 'Which of your sites?', 'owa.go': 'Yes, sign me in', 'owa.cancel': 'No, go back', 'owa.fine': 'No password goes to that site. Only your address, and only if you say yes here.', 'fgate.owa_label': 'Sign in with your own fediverse address', 'fgate.owa_go': 'Continue', 'fgate.owa_hint': 'Your own server confirms who you are. No account and no password here — if you follow this site, you are in.', 'fgate.owa_failed': 'I could not reach that address. Is the spelling right?', 'fgate.login': 'Log in / sign up', 'read.open': 'Replies and reactions',
-    'lbio.empty': 'No links set up yet.',
-    'lbio.back_to_site': 'back to the site',
-    'myst.overview': 'Overview',
-    'myst.title': 'My Klonkt Hub',
-    'myst.quick_links_aria': 'Admin shortcuts',
-    'myst.new_post': 'New post',
-    'myst.appearance': 'Appearance',
-    'myst.comments': 'Comments',
-    'myst.view_site': 'View my site',
-    'myst.account': 'Account',
-    'myst.posts': 'Posts',
-    'myst.published': 'Published',
-    'myst.draft_count_one': '{n} draft',
-    'myst.draft_count_many': '{n} drafts',
-    'myst.draft_badge': 'Draft',
-    'myst.untitled': '(untitled)',
-    'myst.edit': 'Edit',
-    'myst.view': 'View',
-    'arst.title': 'Reset password',
-    'arst.request_new': 'Request a new reset link',
-    'arst.back_login': 'Back to login',
-    'arst.set_for': 'Set a new password for {username}.',
-    'arst.new_pw': 'New password (min 8 characters)',
-    'arst.confirm_pw': 'Confirm new password',
-    'arst.submit': 'Set password',
-    'arrq.title': 'Reset password',
-    'arrq.sent': 'If an account exists for that email address, a reset link has been sent.',
-    'arrq.no_mailserver': '<strong>No mail server configured</strong> — reset link below.',
-    'arrq.no_mail_cli': 'No email set up? The administrator can also run <code>npm run reset-admin</code> on the server.',
-    'arrq.back_login': '← Back to login',
-    'arrq.tagline': 'Enter your email and we\'ll send you a reset link.',
-    'arrq.email': 'Email',
-    'arrq.submit': 'Send reset link',
-    'adir.home': 'Home',
-    'adir.logout': 'Log out',
-    'adir.login': 'Log in',
-    'adir.title': 'Members',
-    'adir.count_one': '{n} Klonkt',
-    'adir.count_many': "{n} Klonkt's",
-    'adir.search_ph': 'Search by name…',
-    'adir.search_aria': 'Search members',
-    'adir.search_btn': 'Search',
-    'adir.clear': 'Clear',
-    'adir.empty_q': 'No members found for "{q}".',
-    'adir.empty': 'No members yet.',
-    'adir.posts_one': '{n} post',
-    'adir.posts_many': '{n} posts',
-    'adir.pager_aria': 'Pagination',
-    'adir.prev': '← Previous',
-    'adir.page_info': 'Page {page} of {pages}',
-    'adir.next': 'Next →',
-    'pusr.post_one': 'post',
-    'pusr.post_many': 'posts',
-    'pusr.on_this_site': 'on this site',
-    'pusr.total': '{n} total',
-    'pusr.joined': 'joined {date}',
-    'pusr.send_dm': 'Send DM',
-    'pusr.empty': 'No posts on this site yet.',
-    'pusr.posts_heading': 'Posts',
-    'pusr.untitled': '(untitled)',
-    'phub.main_badge': 'Main page',
-    'phub.view_page': 'View the page',
-    'phub.members': 'Members',
-    'phub.post_one': 'post',
-    'phub.post_many': 'posts',
-    'phub.all_members': 'All {n} members',
-    'phub.latest_posts': 'Latest posts from our members.',
-    'cfeed.title': 'Circles',
-    'cfeed.sub': "What's happening at the other sites in my circle.",
-    'cfeed.count': '{n} sites in my circle',
-    'cfeed.empty': 'Nothing in my circle yet.',
-    'cfeed.close': 'Close',
-    'cfeed.grid_view': 'Grid view',
-    'cpost.back': 'Circle',
-    'cpost.via': 'via',
-    'cpost.read_more': 'Read more at {source}',
-    'vblk.title': 'Viewer mode',
-    'vblk.text_before': 'This is a read-only account. You can view everything, but',
-    'vblk.text_strong': 'cannot change anything',
-    'vblk.text_after': '— saving, uploading, deleting and commenting are disabled.',
-    'vblk.back': 'Back',
-    'vblk.to_home': 'Go to the main page',
-    'phome.moved_lead': 'This account has moved. You can find me here now:',
-    'phome.moved_hint': 'Already following me? Your server most likely moves you along by itself. If not: follow the new address.',
-    'phome.empty_title': "It's quiet here for now.",
-    'phome.empty_sub': 'No posts yet. Exciting.',
-    'phome.write_first': 'Write your first post',
-    'phome.grid_view': 'Grid view',
-    'fav.title': 'Favorites',
-    'fav.sub': 'Posts you have liked. Tap the ♥ on a post to save it here.',
-    'fav.empty': 'You have no favorites yet. Open a post and tap the heart ♡.',
-    'chlog.back': 'Back',
-    'chlog.title': 'Changes',
-    'chlog.app_version': 'App version',
-    'chlog.fed_proto': 'Federation proto',
-    'chlog.manage_updates': 'Manage updates',
-    'e404.title': 'Page not found',
-    'e404.sub': 'This page no longer exists. The link may be outdated or mistyped.',
-    'e404.home': 'Go to the homepage',
-    'e404.archive': 'Archive',
-    'ptype.eyebrow': 'Type',
-    'ptype.count_one': '{n} post',
-    'ptype.count_many': '{n} posts',
-    'ptype.empty': 'No posts of this type yet.',
-    'ptype.untitled': '(untitled)',
-    'ptag.eyebrow': 'Tag',
-    'ptag.count_one': '{n} post',
-    'ptag.count_many': '{n} posts',
-    'ptag.empty': 'No posts with this tag yet.',
-    'ptag.untitled': '(untitled)',
-    'parch.back': 'Back',
-    'parch.title': 'Archive',
-    'parch.count_one': '{n} post',
-    'parch.count_many': '{n} posts',
-    'parch.empty': 'No posts yet.',
-    'prin.tagline': 'Your direct messages on this site.',
-    'prin.empty': 'No conversations yet. Open someone\'s profile and click "Send DM" to start one.',
-    'prin.empty_conv': 'Empty conversation',
-    'prcv.back_aria': 'Back to inbox',
-    'prcv.inbox': 'Inbox',
-    'prcv.unknown': 'Unknown',
-    'prcv.view_profile': 'View profile',
-    'prcv.placeholder': 'Message…',
-    'prcv.send': 'Send',
-    'acct.lang_label': 'Language',
-    'acct.lang_hint': '— your personal interface language; travels with you across devices and sessions.',
-    'aset.default_lang': 'Default language for visitors',
-    'aset.default_lang_hint': 'What new visitors see before they pick a language themselves. A logged-in user with their own preference sees that instead.',
-    'aset.default_lang_auto': 'Automatic (browser language)',
-    'aset.timezone': 'Timezone',
-    'aset.timezone_hint': 'The timezone used to show and schedule dates and planned posts. Automatic = server default (UTC).',
-    'aset.timezone_auto': 'Automatic (UTC)',
-  },
-  de: {
-    'nav.back_to_site': '← Zurück zur Seite',
-    'nav.fediverse': 'Fediverse',
-    'nav.home': 'Start',
-    'nav.archive': 'Archiv',
-    'nav.search': 'Suche',
-    'nav.theme': 'Thema wechseln',
-    'nav.theme_label': 'Thema', 'nav.dark': 'Dunkel', 'nav.light': 'Hell',
-    'nav.install': 'App installieren',
-    'nav.login': 'Anmelden',
-    'nav.logout': 'Abmelden',
-    'nav.admin': 'Verwaltung',
-    'nav.account': 'Konto',
-    'nav.profile': 'Profil',
-    'nav.favorites': 'Favoriten',
-    'nav.new_post': 'Neuer Beitrag',
-    'nav.language': 'Sprache',
-    'nav.notifications': 'Benachrichtigungen',
-    'notif.title': 'Benachrichtigungen', 'notif.empty': 'Noch keine Benachrichtigungen.', 'notif.someone': 'Jemand', 'notif.followed': 'folgt dir jetzt', 'notif.liked': 'gefällt dein Beitrag', 'notif.boosted': 'teilte deinen Beitrag', 'notif.replied': 'antwortete auf', 'notif.reported': 'hat dich bei ihrem Server gemeldet', 'notif.report_about': 'Zum Beitrag', 'notif.report_noreason': 'Kein Grund angegeben.', 'notif.mentioned': 'hat dich in einem Beitrag erwähnt', 'blk.title': 'Blockieren', 'blk.lead': 'Blockiere ein Konto oder eine ganze Domain — ihre Antworten, Likes und Beiträge verschwinden und neue werden abgelehnt.', 'blk.block_btn': 'Blockieren', 'blk.empty': 'Nichts blockiert.', 'blk.unblock': 'Entsperren', 'tl.block': 'Blockieren',
-    'notif.reply': '{actor} hat auf deinen Kommentar geantwortet', 'notif.comment': '{actor} hat deinen Beitrag kommentiert', 'notif.like': '{actor} gefällt dein Beitrag',
-    'switch.agenda': 'Termine',
-    'switch.solo': 'Solo',
-    'switch.circle': 'Zirkel',
-    'switch.grid': 'Raster', 'switch.reader': 'Lesen', 'switch.timeline': 'Zeitleiste', 'read.to_top': 'Nach oben', 'read.pinned': 'Angeheftet', 'read.next': 'Weiter', 'read.prev': 'Vorheriger Beitrag', 'read.nav': 'Durch die Beitraege', 'read.hint': 'Oben oder unten tippen fuer einen Beitrag zurueck oder weiter', 'asite.reader_full_page': 'Lesen am Desktop: ein Bildschirm pro Beitrag (mobil immer)',
-    'asite.feed_alt': 'Zweite Ansicht', 'asite.feed_alt_reader': 'Lesen', 'asite.feed_alt_timeline': 'Zeitleiste', 'asite.feed_alt_auto': 'Lesen mobil, Zeitleiste am Desktop',
-    'switch.reader_solo_only': 'Lesen gibt es nur in Solo — im Zirkel stehen Beitr\u00e4ge anderer',
-    'postnav.newer': 'Neuer',
-    'postnav.older': 'Älter',
-    'postnav.newest': 'Neuester Beitrag',
-    'postnav.oldest': 'Ältester Beitrag',
-    'footer.subscribe_cta': 'Bleib auf dem Laufenden',
-    'footer.subscribe': 'Abonnieren',
-    'footer.install': 'App installieren',
-    'common.email_placeholder': 'du@email.de',
-    'common.back_to_admin': '← Verwaltung',
-    'agenda.title': 'Termine',
-    'agenda.empty': 'Zurzeit keine angekündigten Termine.',
-    'agenda.tickets': 'Tickets',
-    'agenda.notify_h': 'Keinen Termin verpassen',
-    'agenda.notify_sub': 'Hinterlasse deine E-Mail und wir melden uns bei neuen Terminen. Abmelden jederzeit möglich.',
-    'agenda.notify_btn': 'Halt mich auf dem Laufenden',
-    'agenda.msg_done': '✓ Du stehst auf der Liste — wir melden uns, sobald ein Termin angekündigt wird.',
-    'agenda.msg_check': '✉ Prüfe deine E-Mail, um die Anmeldung zu bestätigen.',
-    'downloads.title': 'Downloads',
-    'downloads.sub': 'Kostenlos herunterladen — hinterlasse deine E-Mail und du bekommst die Datei.',
-    'downloads.empty': 'Momentan keine Downloads verfügbar.',
-    'downloads.btn': '⬇ Herunterladen',
-    'admin.title': 'Verwaltung',
-    'adash.prem_active': 'Premium aktiv',
-    'adash.prem_unlinked': 'Premium nicht verbunden',
-    'adash.prem_layeroff': 'Premium-Ebene aus',
-    'admin.tagline_solo': 'Solo-Modus — deine Seite.',
-    'admin.tagline_cirkels': 'Cirkel-Modus: deine Seite, verbunden mit dem Fediverse.',
-    'admin.b_paid': 'Bezahlte Beiträge', 'admin.b_push': 'Benachrichtigungen', 'admin.back': 'Zurück zur Verwaltung',
-    'push.t': 'Benachrichtigungen', 'push.intro': 'Erhalte auf diesem Gerät eine Meldung bei neuen Followern, Antworten und Nachrichten, auch wenn die Seite geschlossen ist. Verschlüsselt bis in deinen Browser; wir senden so wenig Inhalt wie möglich mit.', 'push.unavailable': 'Push ist auf diesem Server nicht verfügbar (Schlüssel konnte nicht erstellt werden oder die Abhängigkeit fehlt).', 'push.unsupported': 'Dieser Browser unterstützt keine Push-Benachrichtigungen.', 'push.ios_hint': 'Auf iPhone/iPad funktioniert das nur, wenn die Seite auf deinem Home-Bildschirm liegt: Teilen-Knopf, dann "Zum Home-Bildschirm", und öffne sie danach von dort.', 'push.this_device': 'Dieses Gerät:', 'push.checking': 'prüfen…', 'push.state_on': 'Benachrichtigungen sind an', 'push.state_off': 'Benachrichtigungen sind aus', 'push.state_denied': 'in den Browser-Einstellungen blockiert', 'push.state_unknown': 'Status unbekannt', 'push.state_unsupported': 'nicht unterstützt', 'push.enable': 'Auf diesem Gerät einschalten', 'push.disable': 'Ausschalten', 'push.test': 'Testmeldung senden', 'push.what': 'Wofür möchtest du eine Meldung?', 'push.a_follow': 'Neuer Follower', 'push.a_reply': 'Antwort oder Erwähnung', 'push.a_like': 'Like (Stern)', 'push.a_boost': 'Boost', 'push.a_dm': 'Private Nachricht', 'push.saved': 'Gespeichert.', 'push.devices': 'Verbundene Geräte', 'push.device': 'Gerät', 'push.since': 'seit', 'push.remove': 'Entfernen', 'push.enable_failed': 'Einschalten fehlgeschlagen',
-    'push.n_follow_t': 'Neuer Follower', 'push.n_follow_b': '{who} folgt dir jetzt', 'push.n_folreq_t': 'Folgeanfrage', 'push.n_folreq_b': '{who} möchte dir folgen — du entscheidest', 'push.n_reply_t': 'Antwort auf "{title}"', 'push.n_mention_t': 'Erwähnung', 'push.n_dm_t': 'Private Nachricht', 'push.n_dm_b': 'Neue Nachricht von {who}', 'push.n_like_t': 'Neues Like', 'push.n_like_b': '{who} gefällt "{title}"', 'push.n_boost_t': 'Geboostet', 'push.n_boost_b': '{who} hat "{title}" geboostet', 'msg.guard_offer': 'möchte dein Guardian werden. Besprich das mit deinen Eltern oder Betreuern, bevor du entscheidest.', 'msg.guard_accept': 'Annehmen', 'msg.guard_reject': 'Ablehnen', 'msg.guard_accepted': 'Guardian angenommen. Ihr seid jetzt verbunden.', 'msg.guard_rejected': 'Angebot abgelehnt.', 'msg.guard_failed': 'Das hat nicht geklappt; versuch es erneut.', 'msg.guardians_label': 'Deine Guardians', 'msg.waved_at_you': 'hat dir zugewinkt', 'msg.help_request': 'hat um Hilfe gebeten', 'msg.g_available': 'verfuegbar', 'msg.g_away': 'abwesend bis {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Wie schön!', 'msg.wave_r2': 'Ruf mich an', 'msg.wave_back': '👋 Zurück', 'msg.wave_sent': 'Winken gesendet.', 'msg.reply_sent': 'Antwort gesendet.', 'msg.reply_failed': 'Die Antwort konnte nicht gesendet werden.', 'msg.reply_empty': 'Eine leere Antwort kann nicht gesendet werden.', 'guardian.feed_title': 'Deine Wards', 'guardian.feed_sub': 'Lies mit, was deine Wards posten. Nur schauen.', 'guardian.follow_title': 'Follow-Anfragen', 'guardian.follow_sub': 'Jemand möchte einem deiner Wards folgen. Du entscheidest.', 'guardian.wave': '👋 Winken', 'guardian.waved': '👋 gesendet', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards verwalten und Hilferufe auffangen.', 'guardian.acting_as': 'Du handelst als', 'guardian.help_title': 'Hilferufe', 'guardian.help_sub': 'Wenn ein Ward die Rettungsboje nutzt, erscheint es hier.', 'guardian.help_empty': 'Keine Hilferufe. Gut so.', 'guardian.adopt_title': 'Ward adoptieren', 'guardian.adopt_sub': 'Gib das Handle des Kindes ein (@kind@server.eu). Es bekommt ein Angebot in seinem Klonkt zum Annehmen.', 'guardian.adopt_label': 'Ward-Handle', 'guardian.adopt_btn': 'Angebot senden', 'guardian.pending_title': 'Gesendete Angebote', 'guardian.pending_sub': 'Warten, bis der Ward annimmt.', 'guardian.wards_title': 'Meine Wards', 'guardian.play_propose': 'Abspielen vorschlagen', 'guardian.play_on': 'Abspielen: an', 'guardian.play_off': 'Abspielen: aus', 'guardian.gated_title': 'Einstellung vorgeschlagen', 'guardian.gated_line_on': '{who} moechte Link-Vorschauen fuer {ward} EINschalten.', 'guardian.gated_line_off': '{who} moechte Link-Vorschauen fuer {ward} AUSschalten.', 'guardian.gated_agree': 'Einverstanden', 'guardian.gated_disagree': 'Nicht einverstanden', 'guardian.avail_available': 'Verfuegbar', 'guardian.avail_away': 'Abwesend bis {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians dieses Kindes', 'guardian.panel_guards_remote': 'Dieses Kind lebt auf einem anderen Server; die Verfuegbarkeit wird dort gefuehrt.', 'guardian.lapse_propose': 'Vorschlag: Freigabe in Abwesenheit', 'guardian.lapse_line': '{who} antwortet nicht mehr als Guardian von {ward}.', 'guardian.lapse_tally': '{n} von {need} einverstanden; schliesst {date}; das Fenster laeuft immer voll.', 'guardian.lapse_note': 'Jedes Lebenszeichen von ihnen bricht dies sofort ab. Nichts hier ist Strafe.', 'guardian.lapse_agree': 'Einverstanden', 'guardian.lapse_disagree': 'Nicht einverstanden', 'guardian.voted': 'Du hast abgestimmt', 'guardian.away_title': 'Kurz abwesend', 'guardian.away_sub': 'Sag deinen Wards, dass du eine Weile nicht da bist. Entscheidungen warten nicht auf dich, und eine einzige Antwort bringt dich sofort zurueck.', 'guardian.away_week': 'Eine Woche', 'guardian.away_month': 'Ein Monat', 'guardian.away_done': 'Deine Wards wissen, dass du bis {date} abwesend bist.', 'guardian.away_msg': 'Ich bin als dein Guardian bis {date} abwesend. Deine anderen Guardians sind fuer dich da.', 'guardian.release_title': '{who} loslassen?', 'guardian.release_effect': 'Du bist dann nicht mehr Guardian. Du siehst ihre Beitraege nicht mehr, erhaeltst keine Hilferufe mehr von ihnen und entscheidest nicht mehr ueber Folgeanfragen fuer sie. Zurueck geht nur mit einem neuen Angebot, das sie annehmen.', 'guardian.release_local': 'Ihr Server und die anderen Guardians werden benachrichtigt, danach stehst du auch dort nicht mehr als Guardian.', 'guardian.release_step_down': 'Sie behalten ihre anderen Guardians und bleiben also Ward.', 'guardian.release_last': 'Du bist ihr letzter Guardian. Das ist Emanzipation, und FEP-633c 3.4 sagt ausdruecklich, dass darueber kein einzelner Guardian entscheidet: dafuer braucht es drei zustimmende Erwachsene oder eine Mehrheit plus zwei Zeugen. Dieser Knopf kann das also nicht: du bleibst ihr Guardian, bis das geregelt ist.', 'guardian.release_unknown': 'Wir konnten ihren Server nicht erreichen und wissen daher nicht, ob du ihr letzter Guardian bist.', 'guardian.release_yes': 'Ja, loslassen', 'guardian.release_no': 'Nein, doch nicht', 'guardian.settings_title': 'Einstellungen', 'guardian.panel_open': 'Ansehen', 'guardian.panel_close': 'Schliessen', 'guardian.panel_help': 'Hilferufe dieses Kindes', 'guardian.panel_help_empty': 'Noch keine Hilferufe.', 'guardian.panel_follow': 'Folgeanfragen', 'guardian.follow_out_line': 'möchte {who} folgen', 'guardian.panel_follow_empty': 'Keine offenen Folgeanfragen.', 'guardian.panel_posts': 'Neueste Beitraege', 'guardian.panel_posts_empty': 'Noch nichts zu sehen.', 'guardian.panel_actions': 'Aktionen', 'guardian.badge_help': 'Hilferufe', 'guardian.badge_follow': 'Folgeanfragen', 'guardian.badge_follow_one': 'Folgeanfrage', 'guardian.wards_empty': 'Noch keine Wards. Adoptiere oben eins.', 'guardian.push_title': 'Meldungen', 'guardian.push_sub': 'Erhalte eine Meldung bei einem Hilferuf oder einer Vormundschafts-Antwort, auch bei geschlossener App.', 'guardian.push_on': 'Meldungen einschalten', 'guardian.push_off': 'Meldungen sind an; tippen zum Ausschalten', 'guardian.sent': 'Angebot gesendet. Siehe unten bei Gesendete Angebote.', 'guardian.sent_retry': 'Angebot gespeichert; wir versuchen weiter zuzustellen.', 'guardian.sending': 'Senden…', 'guardian.not_found': 'Dieses Handle konnten wir nicht finden.', 'guardian.failed': 'Fehlgeschlagen', 'guardian.network': 'Netzwerkfehler.', 'guardian.pending': 'wartet auf Antwort', 'guardian.active': 'aktiv', 'guardian.retract': 'Zurückziehen', 'guardian.release': 'Loslassen', 'guardian.embeds_on': 'Linkvorschauen: an', 'guardian.embeds_off': 'Linkvorschauen: aus', 'guardian.embeds_propose': 'Linkvorschauen vorschlagen', 'guardian.embeds_waiting': 'wartet auf die anderen Guardians', 'guardian.prop_line': 'Vorschlag {what} {value}: {status}', 'guardian.prop_embeds': 'Link-Vorschauen', 'guardian.prop_play': 'Abspielen', 'guardian.prop_on': 'an', 'guardian.prop_off': 'aus', 'guardian.prop_st_open': 'wartet auf die anderen Guardians', 'guardian.prop_st_accepted': 'angenommen', 'guardian.prop_st_rejected': 'abgelehnt', 'guardian.prop_st_expired': 'abgelaufen ohne genug Stimmen', 'guardian.panel_guards_far': 'Verfuegbarkeit wird auf ihrem Server gefuehrt.', 'guardian.release_confirm': '{who} loslassen?\n\nDu bist dann nicht mehr Guardian. Du siehst ihre Beitraege nicht mehr, erhaeltst keine Hilferufe mehr von ihnen und entscheidest nicht mehr ueber Follow-Anfragen fuer sie.\n\nZurueck geht nur mit einem neuen Angebot, das sie annehmen.', 'guardian.open': 'öffnen', 'guardian.accept': 'Annehmen', 'guardian.reject': 'Ablehnen', 'guardian.complete': 'Abschließen', 'guardian.awaiting_others': 'wartet auf die anderen Parteien', 'guardian.coguard': 'Mit-Vormundschaftsangebot', 'guardian.push_unavailable': 'Push nicht verfügbar', 'push.n_help_t': 'Hilferuf', 'push.n_help_b': '{who} bittet um deine Hilfe', 'push.n_guard_offer_t': 'Vormundschaftsangebot', 'push.n_guard_offer_b': '{who} möchte dich als Guardian', 'push.n_guard_ward_t': 'Ward akzeptiert', 'push.n_guard_left_t': 'Ein Guardian ist zurueckgetreten', 'push.n_guard_left_b': '{who} ist nicht mehr dein Guardian', 'push.n_guard_cogleft_t': 'Mit-Guardian zurueckgetreten', 'push.n_guard_cogleft_b': '{who} hat die Guardianship beendet', 'push.n_guard_ward_b': '{who} hat dich als Guardian akzeptiert', 'push.n_guard_cog_t': 'Mit-Vormundschaft gefragt', 'push.n_guard_cog_b': 'Ein Guardian-Angebot für {who} braucht dich', 'push.n_guard_folin_t': 'Folgeanfrage', 'push.n_guard_folin_b': '{who} möchte {ward} folgen', 'push.n_guard_folout_t': 'Dein Mündel möchte jemandem folgen', 'push.n_guard_folout_b': '{ward} fragt, ob es {who} folgen darf', 'guardian.panel_history': 'Verlauf ({n})', 'guardian.log_show': 'Verlauf zeigen', 'guardian.log_hide': 'Verlauf verbergen', 'guardian.ev_offer_rejected': 'Angebot abgelehnt', 'guardian.ev_offer_refused': 'Angebot verweigert', 'guardian.ev_committed': 'Vormundschaft bestätigt', 'guardian.ev_guardian_left': 'Guardian gegangen', 'guardian.ev_coguardian_left': 'Mit-Guardian gegangen', 'guardian.ev_gated_outcome': 'Tor entschieden', 'guardian.ev_lapse_opened': 'Freigabe vorgeschlagen', 'guardian.evr_not_a_teapot': 'der Kandidat ist selbst ein Mündel', 'guardian.help_archive': '{n} erledigt', 'guardian.help_archive_hide': 'ausblenden', 'guardian.help_former_ward': 'Nicht mehr dein Ward. Ihre anderen Guardians sind weiterhin fuer sie da.', 'guardian.warn_reversible': 'Was hier durchkommt, kommt nicht zurueck. Du kannst diese Einstellung spaeter wieder schliessen — was dein Kind gesehen hat, nicht.', 'guardian.warn_irreversible': 'Das laesst sich nicht rueckgaengig machen. Danach entscheidet sie selbst, und ihr koennt das nicht zuruecknehmen.', 'guardian.warn_unknown': 'Wir kennen diese Einstellung nicht, wissen also nicht, was durchkommt oder wie weit es reicht. Frag die Person, die es vorgeschlagen hat, bevor du zustimmst.', 'guardian.warn_decides': 'DEINE ANTWORT ENTSCHEIDET. Mit dir ist die Schwelle erreicht und es gilt sofort.', 'guardian.warn_not_last': 'Danach muss noch jemand antworten, bevor das gilt.', 'guardian.warn_tally_elsewhere': 'Wir sehen nicht, wie viele Guardians schon geantwortet haben — das zaehlt der Server des Kindes. Dein Ja kann den Ausschlag geben.', 'guardian.warn_go': 'Ja, das vorschlagen', 'guardian.warn_back': 'Nein, doch nicht', 'guardian.help_pick': 'Ich kuemmere mich', 'guardian.help_close': 'Als erledigt markieren', 'guardian.help_picked_by': '{who} kuemmert sich darum', 'guardian.help_handled_by': 'Erledigt von {who}', 'guardian.help_handled_note': 'Das bleibt stehen. Geht es noch weiter, fragt das Kind erneut.', 'guardian.help_close_ask': 'Sicher? Das laesst sich nicht rueckgaengig machen. Geht es noch weiter, bittet das Kind erneut um Hilfe.', 'guardian.help_close_yes': 'Ja, erledigt', 'guardian.help_just_now': 'gerade eben', 'guardian.help_hours': 'vor {n} Std.', 'guardian.help_days': 'vor {n} Tagen', 'guardian.gate_unavailable': 'noch nicht verfuegbar', 'guardian.gate_planned_note': 'Das gibt es auf diesem Server noch nicht.', 'guardian.gates_summary': '{n} Tore - {on} an, {wait} warten', 'guardian.gates_show': 'Tore zeigen', 'guardian.gates_hide': 'Tore ausblenden', 'guardian.gate_images': 'Bilder', 'guardian.gate_messages': 'Nachrichten', 'guardian.gate_asked': 'Dein Kind hat selbst darum gebeten.', 'guardian.gate_replies': 'Antworten in einem Gespraech', 'guardian.gate_compose': 'Selbst posten', 'guardian.gate_music': 'Musik', 'guardian.gate_quoteCards': 'Zitatkarten', 'guardian.gate_customEmoji': 'Eigene Emojis', 'guardian.gate_publicProfile': 'Oeffentlich sichtbar', 'guardian.gate_accountMove': 'Umziehen', 'guardian.gate_independence': 'Selbststaendig werden', 'guardian.gate_externalThreads': 'Antworten von Fremden', 'guardian.gate_externalEmbeds': 'Link-Vorschauen', 'guardian.gate_externalPlayback': 'Abspielen in der App', 'guardian.gate_follows': 'Folgeanfragen', 'guardian.gate_following': 'Anderen folgen', 'guardian.gate_kind_setting': 'Einstellung', 'guardian.gate_kind_perRequest': 'pro Anfrage', 'guardian.gate_kind_handover': 'gibt Zustaendigkeit ab', 'guardian.gate_default_off': 'aus (noch nichts entschieden)', 'guardian.gate_unknown': 'unbekannt', 'guardian.gate_always': 'Immer', 'guardian.gate_threshold': '{need} von {of} Guardians', 'guardian.gate_threshold_unknown': 'Schwelle unbekannt (andere Domain)', 'guardian.gate_irreversible': 'nicht rueckgaengig', 'guardian.gate_waiting': '{n} wartet auf euch', 'guardian.gate_blocked': 'geht erst, wenn {what} an ist', 'guardian.gate_propose_open': 'Vorschlagen: oeffnen', 'guardian.gate_propose_close': 'Vorschlagen: schliessen', 'guardian.gate_propose': 'Vorschlagen', 'push.n_gate_ask_t': 'Deine Antwort wird gebraucht', 'push.n_gate_ask_b': 'Vorschlag fuer {who}: {wat} {stand}', 'push.n_gate_done_t': 'Entscheidung gefallen', 'push.n_gate_done_b': '{wat} {stand} fuer {who}: {uitkomst}', 'push.n_test_t': 'Klonkt-Testmeldung', 'push.n_test_b': 'Funktioniert. So kommen Meldungen auf diesem Gerät an.',
-    'apaid.t': 'Bezahlte Beiträge', 'apaid.intro': 'Verbinde deine eigene Patreon-Kampagne. Unterstützer entsperren bezahlte Beiträge mit einem Passkey, ohne Konto und ohne Cookie. Wir speichern keine Namen oder E-Mail-Adressen von Unterstützern, nur das verschlüsselte Token deiner Kampagne.', 'apaid.saved': 'Gespeichert.', 'apaid.nokey': 'Achtung: der Verschlüsselungsschlüssel konnte nicht erstellt oder gelesen werden (Schreibrechte auf dem Speicherordner?). Ohne Schlüssel können Secrets nicht sicher gespeichert werden.', 'apaid.status': 'Status:', 'apaid.connected': 'verbunden', 'apaid.campaign': 'Kampagne', 'apaid.configured': 'eingerichtet, noch nicht verbunden (Token eintragen)', 'apaid.notyet': 'noch nicht eingerichtet', 'apaid.redirect_h': 'Trage diese Redirect-URI in deinen Patreon-Client ein', 'apaid.redirect_p': 'In deinem Patreon-API-Client muss unter Redirect URIs genau diese Zeile stehen. Stimmt sie nicht, zeigt Patreon eine Fehlermeldung statt deine Unterstützer zurückzuschicken.', 'apaid.copy': 'Kopieren', 'apaid.copied': 'Kopiert', 'apaid.client_id': 'Patreon Client-ID', 'apaid.client_secret': 'Patreon Client-Secret', 'apaid.keep': 'Leer lassen = aktuellen Wert behalten.', 'apaid.campaign_id': 'Kampagnen-ID', 'apaid.public_page': 'Öffentliche Patreon-Seite', 'apaid.public_help': 'Der Link, unter dem Besucher Unterstützer werden können. Wird als "Unterstützer werden" gezeigt, wenn jemand noch nicht spendet.', 'apaid.access': 'Creator Access-Token', 'apaid.refresh': 'Creator Refresh-Token', 'apaid.token_help': 'Access- und Refresh-Token bekommst du auf deiner Patreon-API-Client-Seite. Wir verschlüsseln sie und erneuern automatisch.', 'apaid.min_eur': 'Standard-Unterstützungsbetrag für einen bezahlten Beitrag (Euro)', 'apaid.save': 'Speichern', 'apaid.disconnect': 'Verbindung entfernen', 'apaid.disconnect_confirm': 'Patreon-Verbindung entfernen?', 'apaid.unchanged': 'bleibt unverändert',
-    'pgate.h': 'Für Unterstützer', 'pgate.sub': 'Dieser Beitrag ist für Unterstützer dieser Seite. Werde Unterstützer und entsperre ihn danach mit einem Passkey. Kein Konto auf dieser Seite, kein Cookie.', 'pgate.sub_cents': 'Dieser Beitrag ist für Unterstützer dieser Seite (ab €{eur} pro Monat auf Patreon). Werde Unterstützer und entsperre ihn danach mit einem Passkey. Kein Konto auf dieser Seite, kein Cookie.', 'pgate.join': 'Unterstützer werden auf Patreon', 'pgate.unlock_have': 'Schon Unterstützer? Entsperren', 'pgate.unlock': 'Mit Patreon entsperren', 'pgate.join_short': 'Unterstützer werden', 'pgate.confirm': 'Bestätige mit deinem Passkey…', 'pgate.failed': 'Entsperren fehlgeschlagen. Versuch es erneut.', 'pgate.error': 'Etwas ist schiefgegangen. Versuch es erneut.',
-    'ppk.t': 'Erstelle deinen Passkey', 'ppk.h': 'Du bist Unterstützer, schön.', 'ppk.sub': 'Erstelle jetzt einen Passkey. Er wird dein Schlüssel für bezahlte Beiträge, ohne Konto und ohne Cookie. Wir speichern keinen Namen und keine E-Mail-Adresse.', 'ppk.make': 'Passkey erstellen', 'ppk.unsupported': 'Passkeys werden in diesem Browser nicht unterstützt.', 'ppk.follow': 'Folge der Abfrage deines Geräts…', 'ppk.done': 'Geschafft. Dein Passkey wurde erstellt.', 'ppk.failed': 'Erstellen fehlgeschlagen ({err}). Versuch es erneut.', 'ppk.cancelled': 'Abgebrochen.',
-    'pres.t': 'Entsperren', 'pres.notpatron_h': 'Noch kein Unterstützer', 'pres.notpatron_p': 'Du bist (noch) kein aktiver Unterstützer dieser Seite auf Patreon. Werde Unterstützer und versuch es danach erneut vom Beitrag aus.', 'pres.tier_h': 'Eine Stufe höher nötig', 'pres.tier_p': 'Dieser Beitrag verlangt ab €{need}. Deine Unterstützung ist derzeit €{have}. Erhöhe deine Unterstützung und versuch es erneut.', 'pres.expired_h': 'Anfrage abgelaufen', 'pres.expired_p': 'Dieser Entsperr-Link ist abgelaufen oder wurde schon benutzt. Geh zurück zum Beitrag und versuch es erneut.', 'pres.declined_h': 'Entsperren abgebrochen', 'pres.declined_p': 'Es wurde nichts verbunden. Du kannst es vom Beitrag aus erneut versuchen.', 'pres.join': 'Unterstützer werden auf Patreon', 'pres.back_post': 'Zurück zum Beitrag', 'pres.back_site': 'Zurück zur Seite',
-    'admin.tagline_hub': 'Hub-Modus — eine Firmenseite mit Nutzern, je ein eigener Klonkt Hub.',
-    'admin.b_sites': '🌐 Seiten', 'admin.b_users': '👥 Nutzer', 'admin.b_audio': '🎵 Audio',
-    'admin.b_media': '🎬 Medien', 'admin.t_media': 'Medien', 'admin.media_images': 'Bilder', 'admin.media_videos': 'Videos', 'admin.videos_count': 'Videos', 'admin.videos_empty': 'Noch keine Videos hochgeladen.', 'admin.videos_del_confirm': 'Dieses Video löschen?', 'admin.media_count': 'Bilder', 'admin.media_unused': 'ungenutzt', 'admin.media_cleanup': 'Ungenutzte löschen', 'admin.media_cleanup_confirm': 'Alle ungenutzten Bilder löschen?', 'admin.media_empty': 'Noch keine Bilder hochgeladen.', 'admin.media_copy': 'URL kopieren', 'admin.media_del_confirm': 'Dieses Bild löschen?',
-    'admin.b_playlists': '📃 Playlists', 'admin.b_comments': '💬 Kommentare', 'admin.b_seo': '🔎 SEO',
-    'admin.b_listeners': 'Hörer',
-    'alis.lis_title': 'Hörer',
-    'alis.lis_intro': 'Konten, die deiner BIBLIOTHEK folgen. Sie bekommen deine Musik, und bewusst nicht deine normalen Beiträge — wer ein Plattenregal abonniert, hat nicht um die Zeitung gebeten.',
-    'alis.lis_empty': 'Noch folgt niemand der Bibliothek.',
-    'alis.lis_since': 'Seit',
-    'alis.lis_last': 'Letzte Zustellung',
-    'alis.lis_never': 'noch nichts zugestellt',
-    'alis.lis_count': 'Hörer',
-    'alis.lis_error': 'Zustellung fehlgeschlagen',
-    'ple.titel': 'Titel *',
-    'ple.artiest': 'Künstler',
-    'ple.jaar': 'Jahr',
-    'ple.type': 'Typ',
-    'ple.k_album': 'Album (nummeriert)',
-    'ple.k_playlist': 'Playlist (Titelbilder)', 'ple.k_mixtape': 'Mixtape (Kassette: nur vor und zurück)',
-    'ple.uitgave': 'Veröffentlichungsdatum',
-    'ple.mb_release': 'MusicBrainz-Release-ID',
-    'ple.cover': 'Cover',
-    'ple.cover_kies': 'Bild wählen…',
-    'ple.cover_url': 'https://… oder hochladen',
-    'ple.tracks_in': 'Titel in der Playlist',
-    'ple.sleep_hint': '(⠿ ziehen zum Sortieren)',
-    'ple.beschikbaar': 'Verfügbare Titel',
-    'ple.zoek': 'Suchen…',
-    'ple.geen_res': 'Keine Ergebnisse.',
-    'ple.leeg_sel': 'Klicke rechts auf Titel, um sie hinzuzufügen.',
-    'ple.t_edit': 'Playlist bearbeiten',
-    'ple.t_new': 'Neue Playlist',
-    'ple.dialoog': 'Playlist-Editor',
-    'ple.sluiten': 'Schließen',
-    'ple.annuleren': 'Abbrechen',
-    'ple.opslaan': 'Speichern',
-    'ple.bezig_opslaan': 'Speichern…',
-    'ple.aanmaken': 'Anlegen',
-    'ple.versleep': 'Ziehen',
-    'ple.verwijder': 'Entfernen',
-    'ple.geen_audio': 'Titel hat keine Audiodatei',
-    'ple.e_geen_tracks': 'Keine Audiotitel vorhanden. Lade zuerst welche über Verwaltung → Audio hoch.',
-    'ple.e_tracks': 'Titel konnten nicht geladen werden',
-    'ple.e_opslaan': 'Speichern fehlgeschlagen: ',
-    'ple.e_mislukt': 'Fehlgeschlagen: ',
-    'ple.e_alleen_afb': 'Nur Bilder',
-    'ple.bezig': 'Wird hochgeladen…',
-    'ple.e_upload': 'Upload fehlgeschlagen',
-    'admin.b_settings': '⚙️ Einstellungen', "mig.title": "Migrieren", "mig.plan_new": "auf deinem neuen Klonkt", "mig.plan_from_old": "Du siehst die Instanz, die WEGGEHT. Schritt 1, 3 und 4 machst du auf deinem neuen Klonkt.", "mig.plan_title": "So l\u00e4uft ein Umzug", "mig.plan_hint": "Die Reihenfolge ist nicht beliebig. Schritt 2 muss vor Schritt 3, denn dein altes Klonkt gibt nichts an eine Adresse heraus, die es nicht als Nachfolger kennt. Mit einem Zip darf Schritt 2 dagegen zuletzt: eine Datei fragt niemanden.", "mig.plan_here": "hier", "mig.plan_old": "auf deinem alten Klonkt", "mig.plan_1": "Dein früheres Konto verknüpfen", "mig.plan_1_why": "Ohne das verweigert dein altes Klonkt den Umzug.", "mig.plan_2": "Den Umzug ank\u00fcndigen", "mig.plan_2_why": "Deine Follower ziehen mit. Danach wird das alte Konto gesperrt: kein Posten, Folgen oder Antworten mehr.", "mig.plan_3": "Beitr\u00e4ge und Musik holen", "mig.plan_3_why": "Direkt vom alten Klonkt, oder aus einem Zip, wenn es schon offline ist.", "mig.plan_4": "Deine Folgeliste zur\u00fcckspielen", "mig.plan_5": "Sp\u00e4ter: wenn du die alte Domain aufgibst", "mig.plan_5_why": "Lösche dort zuerst deine alten Beiträge. Dann verschwindet ein fremder Boost sauber, statt als kaputte Karte mit totem Link stehen zu bleiben. Entferne danach oben bei Schritt 1 dein früheres Konto: diese Adresse kontrollierst du dann nicht mehr. Dafür gibt es noch keinen Knopf.", "mig.follows_note": "Oder f\u00fcge den Inhalt deiner CSV oben ein. Die Datei gewinnt, wenn du beides ausf\u00fcllst.", "asite.moved_to_migrate": "Aliasse und Umzug stehen jetzt unter Migrieren, zusammen mit Export, Import und Holen.", "mig.alias_title": "Schritt 1: dein früheres Konto verknüpfen", "mig.alias_hint": "Sag, welches Konto früher deins war. Dein altes Klonkt prüft das, bevor es deine Follower umzieht, und der Hol-Knopf unten braucht es auch.", "mig.alias_label": "Deine früheren Konten, eines pro Zeile", "mig.alias_note": "So, wie du sie jemandem geben würdest: @du@mastodon.social. Höchstens fünf.", "mig.alias_btn": "Speichern", "mig.move_title": "Schritt 2: den Umzug ank\u00fcndigen", "mig.move_hint": "Das sagt allen Followern, dass dein Konto jetzt woanders wohnt. Sie ziehen mit um, und dieses Konto wird danach gesperrt: kein Posten, Folgen, Liken oder Antworten mehr.", "mig.move_label": "Deine neue Adresse", "mig.move_warn": "Das ist der einzige Knopf auf dieser Seite, den du nicht r\u00fcckg\u00e4ngig machen kannst. Dein altes Konto wird danach gesperrt. Nutzt du den Hol-Knopf, MUSS das zuerst: sonst gibt dein altes Klonkt nichts heraus.", "mig.move_btn": "Umzug ank\u00fcndigen", "mig.move_confirm": "Das k\u00fcndigt deinen Umzug allen Followern an und sperrt dieses Konto. Sicher?", "mig.move_done": "Dieses Konto ist bereits umgezogen nach", "mig.r_links_fixed": "Beitr\u00e4ge, deren Links jetzt hierher zeigen", "mig.r_posts_updated": "Beitr\u00e4ge repariert (Bilder lokal geholt)", "mig.r_tracks_updated": "Titel erg\u00e4nzt", "mig.r_tracks_missing": "{n} Titel sind NICHT angekommen, weil die Audiodatei fehlte. Sie wurden bewusst nicht angelegt: ein Titel, der in der Liste steht und nicht spielt, ist schlimmer als ein fehlender.", "mig.c_tracks": "Titel", "mig.c_playlists": "Playlists", "mig.audio_missing": "Achtung: Bei {n} Titel(n) ist die Audiodatei nicht auffindbar. Die kommen nicht mit.", "mig.audio_none": "Achtung: Diese Seite hat Musik, aber es kommt kein einziger Titel mit. Die Dateien liegen vermutlich woanders als die Datenbank denkt.", "mig.pull_title": "Von deinem alten Klonkt holen", "mig.pull_hint": "Bist du schon umgezogen, holt dieses Klonkt deine Beiträge direkt beim alten. Nichts abzutippen: dass du umgezogen bist, ist der Beweis.", "mig.pull_source": "Dein altes Konto", "mig.pull_source_hint": "Aus Schritt 1 übernommen. Stimmt es nicht, ändere es hier.", "mig.pull_btn": "Beitr\u00e4ge holen", "mig.pull_done": "Geholt", "mig.pull_failed": "Holen hat nicht geklappt", "mig.r_blocks": "Blockierungen \u00fcbernommen", "mig.state": "{n} Beitr\u00e4ge haben einen Verweis von ihrer alten Adresse.", "mig.state_done": "Die Liste ist vollst\u00e4ndig.", "mig.state_busy": "Die Liste ist noch nicht vollst\u00e4ndig.", "mig.e_no_source": "Es ist kein altes Konto bekannt. Trage die Adresse oben ein.", "mig.e_unreachable": "Das alte Klonkt ist nicht erreichbar.", "mig.e_not_moved_here": "Dieses Konto ist nicht hierher umgezogen. Kündige den Umzug zuerst auf deinem alten Klonkt an.", "mig.e_no_backreference": "Dieses Konto sagt nirgends, dass das alte Konto deins war. Trage es oben bei Schritt 1 ein, sonst kann dein altes Klonkt nicht erkennen, dass du es bist.", "mig.e_no_outbox": "Das alte Klonkt hat keine Beitragsliste.", "mig.e_partial": "Auf halbem Weg gestoppt. Was da ist, bleibt; versuche es noch einmal.", "mig.e_config": "Dieses Klonkt kennt seine eigene Adresse nicht.", "mig.e_crash": "Etwas Unerwartetes ist schiefgegangen.", "mig.e_points_at": "Dieses Konto verweist auf:", "mig.lead": "Nimm deine Beiträge, Musik und Fotos zu einem anderen Klonkt mit, oder hole sie hierher.", "mig.export_title": "Mitnehmen", "mig.export_hint": "Das erstellt ein Zip mit deinen Beitr\u00e4gen, den Antworten darauf und den zugeh\u00f6rigen Medien.", "mig.c_posts": "Beitr\u00e4ge", "mig.c_replies": "Antworten", "mig.c_media": "Mediendateien", "mig.c_following": "Folge ich", "mig.c_size": "gro\u00df", "mig.missing": "Achtung: Bei {n} Medienverweis(en) liegt die Datei nicht mehr auf der Platte. Die kommen nicht mit.", "mig.too_big": "Dieses Archiv ist zu gro\u00df f\u00fcr die Weboberfl\u00e4che. Nutze scripts/export-archive.mjs auf dem Server.", "mig.export_btn": "Archiv herunterladen (.zip)", "mig.export_none": "Noch nichts zu exportieren.", "mig.import_title": "Hierher holen", "mig.import_hint": "Wähle deine Datei. Du siehst zuerst, was passieren würde; es ändert sich noch nichts.", "mig.file_label": "Deine Archivdatei (.zip)", "mig.overwrite_label": "Beiträge ersetzen, die schon hier sind", "mig.overwrite_hint": "Normalerweise bleibt alles, was hier steht, unangetastet. Schaltest du das ein, wird ein Beitrag mit demselben Namen ersetzt, auch wenn es etwas anderes war. Das kommt nicht zurück.", "mig.check_btn": "Pr\u00fcfen", "mig.check_hint": "Das \u00e4ndert noch nichts. Du siehst zuerst, was passieren w\u00fcrde.", "mig.r_dry": "Was passieren w\u00fcrde", "mig.r_done": "Importiert", "mig.r_would": "k\u00e4men dazu", "mig.r_imported": "dazugekommen", "mig.r_skipped": "\u00fcbersprungen (schon vorhanden)", "mig.r_overwritten": "\u00fcberschrieben", "mig.r_media": "Mediendateien", "mig.r_media_missing": "Medien fehlten im Archiv", "mig.r_new_ids": "Dieses Archiv kommt von einer anderen Webadresse, deine Beiträge liegen hier also unter einer neuen Adresse. Andere Server wissen das noch nicht, also bleiben Antworten und Boosts auf die alte Adresse dort stehen.", "mig.r_confirm_hint": "Sieht gut aus? W\u00e4hle dieselbe Datei noch einmal, um es wirklich zu tun.", "mig.r_confirm_btn": "Jetzt wirklich importieren", "mig.follows_title": "Wem du folgst", "mig.follows_hint": "Deine Folgeliste ist auch im Archiv, und du kannst sie unter Connect separat herunterladen und zur\u00fcckspielen.", "mig.follows_btn": "Zu Connect", "admin.b_migrate": "\ud83d\udce6 Migrieren", 'admin.b_newpost': '✍️ Neuer Beitrag', 'admin.b_look': '🎨 Aussehen',
-    'admin.t_admin': 'Verwaltung', 'admin.t_settings': 'Einstellungen', 'admin.t_audio': 'Audiotracks', 'admin.t_epk': 'Pressemappe bearbeiten', 'admin.t_newsletter': 'Newsletter', 'admin.t_playlists': 'Playlists', 'admin.t_seo': 'SEO', 'admin.t_shows': 'Termine', 'admin.t_sites': 'Sites', 'admin.t_newsite': 'Neue Site', 'admin.t_editsite': 'Bearbeiten: {title}', 'admin.t_stats': 'Statistiken', 'admin.t_updates': 'Updates', 'admin.t_users': 'Benutzer', 'admin.t_hub': 'Mein Klonkt Hub', 'admin.t_manual': 'Handbuch', 'aset.premium_gate': '{feature} ist eine Premium-Funktion — verbinde Patreon in Verwaltung → Einstellungen.',
-    'admin.b_makesite': '🎨 Seite erstellen', 'admin.b_circle': '🔗 Kreis', 'admin.b_stats': '📊 Statistik',
-    'admin.b_newsletter': '✉️ Newsletter', 'admin.b_perskit': '📰 Pressekit', 'admin.b_downloads': '⬇ Downloads',
-    'admin.b_linkbio': '🔗 Link-in-Bio', 'admin.b_agenda': '📅 Termine',
-    'admin.b_updates': '🔄 Updates', 'admin.b_help': '📖 Anleitung', 'admin.b_fediverse': 'Meine Antworten',
-    'admin.st_users': 'Nutzer', 'admin.st_sites': 'Seiten', 'admin.st_posts': 'Beiträge', 'admin.st_published': 'Veröffentlicht',
-    'admin.sec_posts': 'Beiträge', 'admin.sec_sites': 'Seiten', 'admin.sec_users': 'Nutzer',
-    'admin.draft': 'Entwurf', 'admin.edit': 'Bearbeiten', 'admin.view': 'Ansehen',
-    'admin.th_slug': 'Slug', 'admin.th_title': 'Titel', 'admin.th_owner': 'Eigentümer', 'admin.th_created': 'Erstellt',
-    'admin.th_username': 'Benutzername', 'admin.th_email': 'E-Mail', 'admin.th_role': 'Rolle', 'admin.th_joined': 'Mitglied seit',
-    'welcome.title': 'Willkommen bei Klonkt',
-    'welcome.tagline': 'Eine selbstgehostete Publishing-Plattform.',
-    'welcome.have_account': 'Schon ein Konto? Anmelden',
-    'welcome.note': 'Lege dein Administrator-Konto an, um zu starten — danach ist die Registrierung geschlossen.',
-    'welcome.nosite': 'Hallo {user}! Es ist noch keine Seite eingerichtet.',
-    'auth.admin_login_title': 'Administrator-Anmeldung',
-    'auth.username_or_email': 'Benutzername oder E-Mail',
-    'auth.password': 'Passwort',
-    'auth.forgot': 'Passwort vergessen?',
-    'auth.public_sub': 'Melde dich an, um zu kommentieren und Favoriten zu speichern.',
-    'auth.admin_box_q': 'Administrator?',
-    'auth.admin_box_sub': 'Mit Benutzername & Passwort anmelden',
-    'auth.create_admin': 'Administrator anlegen',
-    'auth.reg_intro': 'Ersteinrichtung — lege dein Administrator-Konto an. Das geht nur einmal.',
-    'setup.title': 'Richte dein Klonkt ein',
-    'setup.intro': 'Willkommen! Lass uns deine Seite einrichten — dauert nur eine Minute. Wähle zuerst deine Sprache.',
-    'setup.lang_label': 'Sprache',
-    'setup.f_sitename': 'Name deiner Seite',
-    'setup.sitename_ph': 'z. B. dein Künstlername',
-    'setup.submit': 'Meine Seite erstellen',
-    'setup.username_note': 'Das wird deine Adresse im Fediverse und kann später nicht geändert werden:',
-    'changelog.title': 'Änderungen', 'changelog.empty': 'Kein Änderungsprotokoll verfügbar.',
-    'auth.f_username': 'Benutzername (3-32 Zeichen, Buchstaben/Ziffern/_-)',
-    'auth.f_email': 'E-Mail',
-    'auth.f_password': 'Passwort (min. 8 Zeichen)',
-    'tab.home': 'Start', 'tab.search': 'Suche', 'tab.write': 'Schreiben', 'tab.profile': 'Profil',
-    'comments.heading_one': '{n} Kommentar', 'comments.heading_other': '{n} Kommentare',
-    'comments.empty': 'Noch keine Kommentare.',
-    'fedi.heading': 'Aus dem Fediverse', 'fedi.likes': 'Favoriten', 'fedi.boosts': 'Boosts', 'fedi.replies': 'Antworten aus dem Fediverse',
-    'fedi.reply': 'Antworten', 'fedi.reply_ph': 'Deine Antwort an das Fediverse…', 'fedi.send': 'Senden', 'fedi.you': 'Du',
-    'fedi.remote_title': 'Über das Fediverse antworten', 'fedi.follow_heading': 'Über das Fediverse folgen', 'fedi.profile_follow': 'Über das Fediverse folgen', 'profile.since': 'Auf Klonkt seit', 'profile.free': 'Kostenlos', 'fedi.follow_intro': 'Du folgst gleich:', 'fedi.follow_btn': 'Folgen', 'fedi.cancel': 'Abbrechen', 'fedi.followed_title': 'Folge-Anfrage gesendet ✅', 'fedi.followed_done': 'Deine Folge-Anfrage ist unterwegs. Sobald sie akzeptiert wird, erscheinen ihre Beiträge in deiner Timeline.', 'fedi.view_profile': 'Profil ansehen →', 'fedi.remote_reply': 'Über das Fediverse antworten', 'fedi.remote_prompt': 'Deine Fediverse-Adresse:', 'fedi.remote_notfound': 'Beitrag konnte nicht geladen werden. Füge die vollständige Beitrags-URL ein:', 'fedi.remote_load': 'Laden', 'fedi.remote_replying_to': 'Antwort an', 'fedi.remote_as': 'Wird als {site} gesendet.', 'fedi.remote_view_original': 'Ganzen Beitrag + Kommentare an der Quelle ansehen →', 'fedi.remote_reply_short': 'übers Fediverse', 'fedi.like_short': 'Liken', 'fedi.unlike_short': 'Like zurücknehmen', 'fedi.boost_short': 'Boosten', 'fedi.remote_ph': 'dein Server', 'fedi.remote_sent_title': 'Gesendet ✅', 'fedi.remote_sent': 'Deine Antwort wurde gesendet. Sie erscheint gleich beim Originalbeitrag im Fediverse, nicht auf dieser Seite. Sieh sie dir dort an:', 'fedi.reply_where': 'Deine Antwort erscheint beim Originalbeitrag im Fediverse, nicht auf dieser Seite. Über den Link oben siehst du sie dort.', 'fedi.remote_back': '← Zurück zu deiner Seite', 'fedi.like_btn': 'Diesen Beitrag liken', 'fedi.or_reply': 'oder antworten:', 'fedi.liked_title': 'Geliked', 'fedi.liked_done': 'Dein Like ist unterwegs ins Fediverse.', 'fedi.boost_btn': 'Diesen Beitrag boosten', 'fedi.boosted_title': 'Geboostet', 'fedi.boosted_done': 'Dein Boost ist unterwegs ins Fediverse.', 'fedi.remote_interact': 'Übers Fediverse interagieren', 'fedi.report_open': 'Diesen Beitrag melden', 'fedi.report_where': 'Die Meldung geht an die Instanz und deren Moderator(en).', 'fedi.report_ph': 'Was ist das Problem? (optional)', 'fedi.report_send': 'Melden', 'fedi.reported_title': 'Gemeldet', 'fedi.reported_done': 'Deine Meldung wurde an den Server der Person gesendet. Deren Moderatoren prüfen sie.', 'fedi.delete_confirm': 'Diese Antwort löschen?', 'fedi.mod_remove_confirm': 'Diese Antwort aus deinem Thread entfernen? Sie kommt nicht zurück, auch nicht über Thread-Auffüllung.', 'fedi.mod_report_confirm': 'Diese Antwort beim Server des Autors melden?', 'fedi.manage_title': 'Meine Fediverse-Antworten', 'fedi.manage_empty': 'Du hast noch keine Antworten gesendet.', 'fedi.goto_post': 'Zur Post', 'fedi.edit': 'Bearbeiten', 'fedi.save_edit': 'Speichern', 'fedi.bm_label': 'Über meine Seite interagieren', 'fedi.bm_help': 'Zieh diesen Button in deine Lesezeichenleiste. Klick ihn dann auf einem beliebigen Fediverse-Beitrag (Mastodon, ein anderes Klonkt…), um über deine eigene Seite zu antworten, zu liken oder zu boosten.', 'tl.title': 'Zeitung', 'tl.lead': 'Folge Konten im Fediverse und sieh ihre Beiträge hier.', 'tl.follow_btn': 'Folgen', 'tl.following': 'Du folgst', 'tl.unfollow': 'Entfolgen', 'tl.autoboost': 'Hervorgehoben', 'tl.autoboost_follow': 'im Zirkel hervorheben', 'tl.autoboost_hint': 'Ihre neuen Beiträge erscheinen laufend in deinem Zirkel (lokal, kein Fediverse-Boost).', 'tl.moved_title': 'Dieses Konto ist umgezogen', 'tl.moved_lead': 'Neue Beiträge, Folgen, Likes und Antworten laufen jetzt über', 'tl.moved_hint': 'Lesen funktioniert hier weiterhin, und Antworten auf deine alten Beiträge kommen noch an. Zurück? Leere das Umzugsziel unter Aussehen.', 'tl.move_title': 'Deine Folgeliste mitnehmen', 'tl.move_hint': 'Ziehst du auf eine andere Adresse um? Deine Follower erfahren das automatisch, wem DU folgst aber nicht. Nimm diese Liste hier mit. Funktioniert auch von und zu Mastodon.', 'tl.move_export': 'Liste herunterladen (CSV)', 'tl.move_import_file': 'Wähle deine heruntergeladene CSV-Datei:', 'tl.move_import_lbl': 'Oder füge die Liste hier ein:', 'tl.move_import': 'Allen folgen', 'tl.pending': 'ausstehend', 'tl.unboost': 'Boost zurücknehmen', 'tl.feed': 'Beiträge', 'tl.tab_feed': 'Zeitung', 'tl.tab_following': 'Folge ich', 'tl.tab_replies': 'Antworten', 'tl.tab_followers': 'Follower', 'tl.followers': 'Follower', 'tl.followers_lead': 'Wer dir im Fediverse folgt, mit der letzten erfolgreichen Zustellung. Rot = nie zugestellt oder letzter Versuch fehlgeschlagen — nach einer Prüfung ein Kandidat zum Aufräumen.', 'tl.empty_followers': 'Noch keine Follower.', 'tl.last_delivery': 'Letzte Zustellung', 'tl.never_delivered': 'Nie zugestellt', 'tl.delivery_failed': 'letzter Versuch fehlgeschlagen', 'tl.remove_follower': 'Entfernen', 'tl.folreq_title': 'Folgeanfragen', 'tl.folreq_sub': 'Diese warten auf dein Ja oder Nein. Bis dahin sieht die anfragende Person nichts von deinen Beiträgen.', 'tl.folreq_accept': 'Annehmen', 'tl.folreq_deny': 'Ablehnen', 'tl.approve_toggle': 'Follower zuerst genehmigen', 'tl.approve_toggle_hint': 'An: Folgeanfragen warten hier auf dein Ja. Aus: alle dürfen sofort folgen.', 'tl.remove_confirm': 'Diesen Follower entfernen? Ein aktives Konto müsste dir erneut folgen.', 'tl.tab_connect': 'Connect', 'tl.connect': 'Connect', 'tl.dir_following': 'du folgst', 'tl.dir_follower': 'folgt dir', 'tl.dir_mutual': 'gegenseitig', 'tl.connect_empty': 'Noch keine Verbindungen. Folge oben jemandem, um zu starten.', 'tl.unreachable': 'Nicht erreichbar', 'tl.unreachable_lead': 'Diese Follower konnten wir nicht erreichen (nie zugestellt oder letzter Versuch fehlgeschlagen). Räume sie nach einer Prüfung auf.', 'msg.tab': 'Nachrichten', 'msg.title': 'Nachrichten', 'msg.filter_all': 'Alle', 'msg.filter_msgs': 'Nachrichten', 'msg.filter_conv': 'Gespräche', 'msg.filter_act': 'Aktivität', 'msg.filter_mod': 'Moderation', 'msg.filter_sent': 'Gesendet', 'msg.search_ph': 'Nachrichten durchsuchen…', 'msg.no_match': 'Nichts gefunden.', 'msg.poll_done': 'Deine Umfrage ist beendet', 'msg.poll_total': '{n} Teilnehmer', 'msg.you': 'Du', 'msg.sent_reply': 'hat über das Fediverse geantwortet', 'msg.and_more': 'und {n} andere', 'msg.liked_many': 'gefällt dein Beitrag', 'msg.boosted_many': 'teilten deinen Beitrag', 'msg.private': 'privat', 'msg.private_hint': 'Nur an dich gerichtet; erscheint nicht auf der öffentlichen Beitragsseite.', 'msg.new': 'Neu seit deinem letzten Besuch', 'msg.empty': 'Noch keine Nachrichten. Antworten, Erwähnungen und Aktivität erscheinen hier.', 'oauth.title': 'App autorisieren', 'oauth.wants_access': 'möchte sich mit deinem Klonkt-Konto verbinden.', 'oauth.post_as': 'Posten als', 'oauth.scope_read': 'Deine Beiträge, Antworten und Meldungen lesen', 'oauth.scope_write': 'In deinem Namen posten, antworten, liken und folgen', 'oauth.allow': 'Erlauben', 'oauth.deny': 'Ablehnen', 'oauth.foot': 'Du kannst den Zugriff später widerrufen. Autorisiere nur Apps, denen du vertraust.', 're.title': 'Antwort schreiben', 're.bold': 'Fett', 're.italic': 'Kursiv', 're.link': 'Link einfügen', 're.list': 'Aufzählung', 're.quote': 'Zitat', 're.lang': 'Sprache deiner Antwort', 're.attach': 'Medien hinzufügen (Bild, Audio, Video)', 're.attach_err': 'Upload fehlgeschlagen', 're.to': 'An:', 're.mention_del': 'Diese Person nicht mehr adressieren', 'tl.empty_following': 'Du folgst noch niemandem.', 'tl.empty': 'Noch nichts — folge jemandem, um Beiträge hier zu sehen.', 'tl.view_original': 'Original ansehen →', 'tl.open_player': 'Player öffnen', 'feed.load_more': 'Mehr laden', 'tl.paste_ph': 'URL eines Fediverse-Beitrags einfügen', 'tl.paste_go': 'Öffnen', 'tl.boosted': 'hat geteilt', 'tl.read_more': 'Mehr lesen', 'tl.show_less': 'Weniger', 'poll.vote': 'Abstimmen', 'poll.votes': 'Stimmen', 'poll.closed': 'geschlossen', 'poll.open': 'offen', 'poll.aria': 'Umfrage', 'poll.voter_one': 'Teilnehmer', 'poll.voter_many': 'Teilnehmer', 'poll.closes': 'endet am', 'poll.multiple': 'Mehrfachauswahl', 'poll.fedi_only': 'Abstimmen geht über das Fediverse — folge dieser Seite und stimme in deiner eigenen App ab.', 'poll.voted_title': 'Stimme gesendet', 'poll.voted_done': 'Deine Stimme ist unterwegs zur Umfrage. Die Ergebnisse aktualisieren sich, sobald die Autorin oder der Autor das Update sendet.',
-    'comments.to_start': 'um das Gespräch zu starten.',
-    'comments.reply': 'Antworten', 'comments.delete': 'Löschen', 'comments.cancel': 'Abbrechen',
-    'comments.delete_confirm': 'Diesen Kommentar löschen?',
-    'comments.reply_to': 'Antwort an {name}…',
-    'comments.add_as': 'Kommentieren als', 'comments.placeholder': 'Teile deine Gedanken…',
-    'comments.post': 'Kommentar posten', 'comments.login_to_comment': 'Zum Kommentieren anmelden',
-    'comments.pending': 'Dein Kommentar wartet auf Freigabe. Er erscheint, sobald ein Administrator ihn genehmigt.',
-    'related.title': 'Ähnliche Beiträge',
-    'search.placeholder': 'Beiträge und Titel suchen…', 'search.button': 'Suchen',
-    'search.error': 'Diese Suche konnte nicht ausgeführt werden. Versuch einen einfacheren Begriff.',
-    'search.results_one': '{n} Ergebnis für „{q}“', 'search.results_other': '{n} Ergebnisse für „{q}“',
-    'search.section_tracks': 'Titel', 'search.section_posts': 'Beiträge',
-    'search.empty': 'Nichts gefunden.', 'search.in_post': 'im Beitrag →',
-    'search.section_events': 'Veranstaltungen', 'search.section_pages': 'Seiten',
-    'search.page_agenda': 'Termine', 'search.page_downloads': 'Downloads', 'search.page_links': 'Links', 'search.page_perskit': 'Pressekit', 'search.page_archive': 'Archiv',
-    'search.suggest_all': 'Alle Ergebnisse →', 'search.suggest_empty': 'Keine Ergebnisse', 'search.suggest_typing': 'Zum Suchen tippen…',
-    'like.login_title': 'Zum Liken anmelden', 'like.fedi_title': 'Diesen Beitrag von deinem Fediverse-Konto liken',
-    // === Verwaltungs-Unterseiten ===
-    'aset.title': 'Einstellungen',
-    'aset.back_admin': 'Verwaltung',
-    'aset.mode': 'Modus',
-    'aset.mode_help': 'Legt fest, wie diese Installation funktioniert. Das Wechseln ist sicher: Es wird nichts gelöscht — Solo blendet lediglich die Multi-Site-Funktionen aus und zeigt deine primäre Site.',
-    'aset.solo': 'Solo',
-    'aset.solo_title': 'eine Site (deine).',
-    'aset.solo_desc': 'Kein Benutzerverzeichnis, kein Site-Wechsel.',
-    'aset.premium_badge': 'Premium',
-    'aset.circle': 'Kreise',
-    'aset.circle_title': 'Solo + Föderation.',
-    'aset.circle_desc': 'Eine eigene Site, die die öffentlichen Beiträge anderer Klonkt-Sites anzeigt. Asymmetrisch: Du bestimmst, wer in deinem Kreis ist.',
-    'aset.save': 'Speichern',
-    'aset.name': 'Name',
-    'aset.name_ph': 'z. B. Studio Noord',
-    'aset.tagline': 'Tagline',
-    'aset.tagline_ph': 'z. B. Unabhängiges Musiklabel',
-    'aset.intro': 'Intro',
-    'aset.intro_ph': 'Kurzer Introtext unter dem Titel.',
-    'aset.hero_image': 'Hero-Bild (URL)',
-    'aset.hero_image_hint': 'optional; Hintergrund des Heros',
-    'aset.hero_upload': '…oder lade ein Bild hoch',
-    'aset.hero_upload_hint': 'jpg/png/webp/gif, max. 5 MB; ersetzt die URL oben',
-    'aset.hero_overlay': 'Dunkles Overlay',
-    'aset.hero_overlay_hint': 'verdunkelt das Hero, damit der Text lesbar bleibt',
-    'aset.preview_overlay': 'Vorschau (mit Overlay):',
-    'aset.hero_preview_alt': 'Hero-Vorschau',
-    'aset.your_circle': 'Dein Kreis',
-    'aset.your_circle_help': 'Verwalte, welche anderen Klonkt-Sites du in deinem Kreis anzeigst und ob deine Site in den Kreisen anderer erscheinen darf. Asymmetrisch: Du bestimmst, wem du folgst.',
-    'aset.manage_circle': 'Deinen Kreis verwalten',
-    'aset.premium': 'Premium (Patreon)',
-    'aset.premium_help_1': 'Schalte die Premium-Module (Newsletter, Downloads, Statistiken, EPK, Link-in-Bio, Termine) mit deiner',
-    'aset.premium_lifetime': '$16-Lifetime',
-    'aset.premium_help_2': 'Patreon-Unterstützung frei. Die App und alle Updates bleiben kostenlos.',
-    'aset.premium_active': 'Premium aktiv.',
-    'aset.lifetime_support': 'Lifetime-Unterstützung:',
-    'aset.patreon_disconnect': 'Patreon trennen',
-    'aset.patreon_no_lifetime': 'Patreon verbunden, aber noch keine $16 Lifetime',
-    'aset.now': 'derzeit',
-    'aset.patreon_support_again': 'Unterstütze die Kampagne und verbinde erneut.',
-    'aset.patreon_reconnect': 'Erneut verbinden',
-    'aset.patreon_not_connected': 'Noch nicht verbunden.',
-    'aset.patreon_connect': 'Patreon verbinden',
-    'aset.status_set': 'Status: eingerichtet',
-    'aset.not_set_yet': 'Noch nicht eingerichtet.',
-    'aset.newsletter': 'Newsletter',
-    'aset.newsletter_help_1': 'Zeige ein',
-    'aset.newsletter_footer_field': 'Anmeldefeld in der Fußzeile',
-    'aset.newsletter_help_2': 'deiner Site, damit sich Besucher auf jeder Seite eintragen können. (Die vollständige Anmeldeseite bleibt unter',
-    'aset.newsletter_show_footer': 'Anmeldefeld in der Fußzeile anzeigen',
-    'aset.fediverse': 'Fediverse (ActivityPub)',
-    'aset.fediverse_help': 'Lass deine Seite am Fediverse teilnehmen: Leute auf Mastodon (oder einem anderen Klonkt) können dir folgen, liken und antworten — und diese Antworten erscheinen unter deinen Beiträgen. Schaltest du das aus, föderiert deine Seite nicht und hat keine Kommentare (ein ruhiger, eigenständiger Blog).',
-    'aset.fediverse_toggle': 'Fediverse an (Folgen, Liken, Kommentieren)', 'aset.mode': 'Modus', 'aset.mode_help': 'Wähle, wie deine Site funktioniert.', 'aset.mode_solo': 'Solo', 'aset.mode_solo_help': 'Ein eigenständiger Blog — kein Fediverse, keine Kommentare. Ruhig und in sich geschlossen.', 'aset.mode_cirkels': 'Kreise', 'aset.mode_18plus': 'Das Fediverse ist ein offenes Netzwerk, das auch Inhalte für Erwachsene (18+) enthalten kann — du musst alt genug sein, um teilzunehmen.', 'aset.mode_18plus_confirm': 'Kreise verbindet deine Seite mit dem Fediverse, einem offenen Netzwerk mit auch 18+-Inhalten. Bestätige, dass du alt genug bist, um dies zu aktivieren.', 'aset.mode_cirkels_help': 'Mach beim Fediverse mit (ActivityPub): Leute auf Mastodon oder einem anderen Klonkt können dir folgen, liken und antworten, und du kannst einem Kreis von Sites folgen.',
-    'aset.email_smtp': 'E-Mail (SMTP)',
-    'aset.smtp_help_1': 'Erforderlich, um den',
-    'aset.smtp_help_newsletter': 'Newsletter zu versenden',
-    'aset.smtp_help_2': ',',
-    'aset.smtp_help_notify': 'Show-Notify',
-    'aset.smtp_help_3': '-Mails zu senden und das Zurücksetzen des Passworts per Mail zu ermöglichen. Gib die Daten deines Mailanbieters ein (z. B. deine Hosting-Mail, ein Gmail-App-Passwort, Brevo, Mailgun…).',
-    'aset.via_env': 'über .env',
-    'aset.smtp_not_set': 'Noch nicht eingerichtet — Versenden funktioniert noch nicht.',
-    'aset.smtp_host': 'SMTP-Host',
-    'aset.smtp_port': 'Port',
-    'aset.smtp_port_hint': '587 (STARTTLS) oder 465 (SSL)',
-    'aset.smtp_user': 'Benutzername',
-    'aset.smtp_pass': 'Passwort',
-    'aset.smtp_pass_set_hint': 'eingerichtet; leer lassen = unverändert',
-    'aset.smtp_pass_ph_set': '•••••••• (eingerichtet)',
-    'aset.smtp_pass_ph': 'App-Passwort',
-    'aset.smtp_from': 'Absender',
-    'aset.smtp_from_hint': 'optional; Standard = Benutzername',
-    'aset.smtp_from_ph': 'Dein Name <du@deinanbieter.de>',
-    'aset.smtp_save': 'SMTP speichern',
-    'aset.clear': 'Löschen',
-    'aset.test_mail_to': 'Testmail senden an',
-    'aset.send_test_mail': 'Testmail senden',
-    'asite.back_admin': 'Verwaltung',
-    'asite.title_new': 'Neue Seite',
-    'asite.title_edit': 'Erscheinungsbild',
-    'asite.identity': 'Identität',
-    'asite.slug': 'Slug (URL)',
-    'asite.slug_fixed': '(fest)',
-    'asite.slug_placeholder': 'deinslug',
-    'asite.field_title': 'Titel',
-    'asite.field_title_hint': '— wird im Kopf und als Anzeigename gezeigt',
-    'asite.owner': 'Eigentümer',
-    'asite.owner_hint': '— wer dieses Klonkt selbst verwalten darf',
-    'asite.owner_god_suffix': ' (god)',
-    'asite.tagline': 'Tagline',
-    'asite.tagline_hint': '— kurzer Einzeiler',
-    'asite.bio': 'Bio / Beschreibung',
-    'asite.bio_hint': '— wird im Profilkopf gezeigt und für SEO verwendet',
-    'asite.profile_photo': 'Profilfoto',
-    'asite.photo_url_placeholder': '/media/avatars/foo.jpg oder https://…',
-    'asite.photo_upload': '📷 Hochladen',
-    'asite.photo_clear': 'Entfernen',
-    'asite.language': 'Sprache (ISO-Code)',
-    'asite.profile_enabled': 'Profilkopf unter der Navigation anzeigen',
-    'asite.appearance': 'Gestaltung',
-    'asite.accent_color': 'Akzentfarbe',
-    'asite.theme_default': 'Standardthema für neue Besucher',
-    'asite.theme_auto': 'Auto (Geräteeinstellung folgen)',
-    'asite.theme_light': 'Hell',
-    'asite.theme_dark': 'Dunkel',
-    'asite.palette': 'Palette',
-    'asite.behavior': 'Verhalten',
-    'asite.is_public': 'Öffentliche Seite (abwählen für einen geschlossenen Kreis)',
-    'asite.robots_index': 'Suchmaschinen dürfen indexieren (sitemap.xml ist ausgeblendet, wenn aus)',
-    'asite.require_login_comment': 'Anmeldung zum Kommentieren erforderlich',
-    'asite.enable_audio': 'Audioplayer + Embeds aktivieren', 'asite.approve_followers': 'Follower zuerst genehmigen (Folgeanfragen warten auf dein Ja auf der Connect-Seite)',
-    'asite.links': 'Social- / Streaming-Links',
-    'asite.links_hint': 'Werden als Marken-Icons im Profilkopf gezeigt. Füge so viele hinzu, wie du möchtest.',
-    'asite.aliases': 'Fediverse-Aliasse',
-    'asite.move': 'Umzug (Fediverse)',
-    'asite.move_hint': 'Kündige deinen Followern an, dass dieses Konto woanders weitergeht. Das neue Profil muss diese Adresse zuerst als Alias beanspruchen; Follower ziehen dann automatisch mit. Ein Konto mit Guardians kann noch nicht umziehen.',
-    'asite.move_confirm': 'Bist du sicher? Deine Follower erfahren, dass dieses Konto umgezogen ist.',
-    'asite.move_btn': 'Umzug ankündigen',
-    'asite.moved_to': 'Umgezogen nach',
-    'asite.aliases_hint': 'Einer pro Zeile: dein altes Konto als @name@server oder als Actor-URL. Nötig, um Follower eines alten Kontos hierher umzuziehen; der alte Server prüft, ob dieses Profil das alte beansprucht.',
-    'asite.link_add': '+ Link hinzufügen',
-    'asite.feed_view': 'Feed-Anzeige',
-    'asite.feed_default': 'Standardansicht für die Startseite',
-    'asite.feed_reader': 'Lesen (ganze Beiträge, einer pro Bildschirm)',
-    'asite.feed_grid': 'Raster (Karten)',
-    'asite.feed_switch': 'Umschalter Zeitstrahl ↔ Raster über dem Feed anzeigen',
-    'asite.show_search': 'Suchschaltfläche in der Navigation anzeigen',
-    'asite.show_archive': 'Archiv-Link in der Navigation anzeigen',
-    'asite.seo': 'SEO & Social',
-    'asite.seo_pointer': 'Titelvorlage, Canonical, Teilbild, Verifizierungs-Metas und mehr befinden sich jetzt auf einer eigenen Seite:',
-    'asite.seo_link': '🔎 SEO & Auffindbarkeit',
-    'asite.custom_legend': 'Eigenes CSS & HTML',
-    'asite.optional': '(optional)',
-    'asite.custom_css': 'Eigenes CSS (eingefügt als &lt;style&gt; im &lt;head&gt;)',
-    'asite.custom_head': 'Eigenes &lt;head&gt;-HTML (Analytics, zusätzliche Metas)',
-    'asite.custom_foot': 'Eigenes Footer-HTML',
-    'asite.submit_create': 'Seite erstellen',
-    'asite.submit_save': 'Änderungen speichern',
-    'aseo.back': 'Verwaltung',
-    'aseo.title': 'SEO & Auffindbarkeit',
-    'aseo.tagline_pre': 'Erweiterte SEO von',
-    'aseo.tagline_post': '— wie deine Seite in Suchmaschinen und beim Teilen in sozialen Medien erscheint.',
-    'aseo.index_legend': 'Indexierung',
-    'aseo.index_label': 'Suchmaschinen dürfen diese Seite indexieren',
-    'aseo.index_hint_pre': 'aus =',
-    'aseo.index_hint_post': '+ sitemap.xml verborgen',
-    'aseo.title_legend': 'Titel & Beschreibung',
-    'aseo.title_template': 'Titelvorlage',
-    'aseo.title_template_hint_pre': 'verwende',
-    'aseo.title_template_hint_and': 'und',
-    'aseo.default_desc': 'Standardbeschreibung',
-    'aseo.default_desc_hint': 'Meta-Description / og:description, wenn eine Seite keine hat',
-    'aseo.default_desc_ph': 'Kurze Beschreibung deiner Seite (max. ~160 Zeichen funktionieren am besten)',
-    'aseo.canonical': 'Kanonische Basis-URL',
-    'aseo.canonical_hint': 'die Produktions-HTTPS-URL, verhindert Strafen für doppelte Inhalte',
-    'aseo.author': 'Autor',
-    'aseo.author_hint': 'Meta-Author-Tag',
-    'aseo.author_ph': 'Dein Name',
-    'aseo.social_legend': 'Teilen in sozialen Medien',
-    'aseo.og_image': 'Standard-Teilbild (URL)',
-    'aseo.og_image_hint': 'og:image / Twitter-Card; ~1200×630px',
-    'aseo.og_theme': 'Teilen-Karte hell oder dunkel',
-    'aseo.og_theme_hint': 'das automatisch erzeugte Teilbild',
-    'aseo.og_theme_auto': 'Automatisch (folgt dem Site-Thema)',
-    'aseo.og_theme_light': 'Hell',
-    'aseo.og_theme_dark': 'Dunkel',
-    'aseo.og_locale': 'Sprach-Locale',
-    'aseo.og_locale_hint_pre': 'og:locale, z. B.',
-    'aseo.og_locale_hint_or': 'oder',
-    'aseo.twitter': 'Twitter-/X-Handle',
-    'aseo.twitter_hint': 'mit @',
-    'aseo.fb_app': 'Facebook App-ID',
-    'aseo.fb_app_hint': 'fb:app_id (optional)',
-    'aseo.publisher_legend': 'Herausgeber (JSON-LD / Rich Results)',
-    'aseo.type': 'Typ',
-    'aseo.type_person': 'Person',
-    'aseo.type_org': 'Organisation / Unternehmen',
-    'aseo.publisher_name': 'Name',
-    'aseo.publisher_name_hint': 'fällt auf den Seitentitel zurück',
-    'aseo.publisher_url': 'URL',
-    'aseo.publisher_logo': 'Logo (URL)',
-    'aseo.verify_legend': 'Suchmaschinen-Verifizierung',
-    'aseo.verify_google': 'Google-Site-Verifizierung',
-    'aseo.verify_bing': 'Bing',
-    'aseo.verify_bing_hint': 'msvalidate.01',
-    'aseo.verify_pinterest': 'Pinterest',
-    'aseo.verify_pinterest_hint': 'p:domain_verify',
-    'aseo.verify_yandex': 'Yandex',
-    'aseo.save': 'SEO speichern',
-    'aaud.title': 'Audio-Tracks',
-    'aaud.tagline_pre': 'MP3s auf Site-Ebene. Verwende',
-    'aaud.tagline_post': 'in einem Beitrag, um eine Wiedergabe-Schaltfläche einzufügen.',
-    'aaud.upload': 'Upload',
-    'aaud.artist': 'Künstler',
-    'aaud.album': 'Album',
-    'aaud.applied_all': '(auf alle Dateien angewendet)',
-    'aaud.optional': 'Optional',
-    'aaud.cover': 'Cover',
-    'aaud.cover_hint': '(optional, auf alle Dateien angewendet — jpg/png/webp/gif, max. 5 MB)',
-    'aaud.choose_cover': 'Cover wählen',
-    'aaud.no_file': 'Keine Datei gewählt',
-    'aaud.drag_here': 'Audio hierher ziehen',
-    'aaud.or_click': 'oder klicken, um Dateien zu wählen',
-    'aaud.start_upload': 'Upload starten',
-    'aaud.clear_list': 'Liste leeren',
-    'aaud.tracks': 'Tracks',
-    'aaud.add_link_track': 'Track ohne Audio',
-    'aaud.add_link_track_title': 'Ein Track ohne Audiodatei — nur Titel + Öffnen-in-Links',
-    'aaud.no_tracks': 'Noch keine Tracks. Lade oben einen hoch.',
-    'aaud.untitled': '(ohne Titel)',
-    'aaud.copy_click': 'Zum Kopieren klicken',
-    'aaud.play': 'Abspielen',
-    'aaud.pause': 'Pausieren',
-    'aaud.edit': 'Bearbeiten',
-    'aaud.delete': 'Löschen',
-    'aaud.delete_confirm': 'Track löschen?',
-    'aaud.dl_on': 'Download-für-E-Mail ist AN — zum Ausschalten klicken',
-    'aaud.dl_off': 'Download-für-E-Mail ist aus — zum Einschalten klicken',
-    'aaud.fedi_on': 'Im Fediverse geteilt (spielt überall inline, Datei herunterladbar) — zum Ausschalten klicken',
-    'aaud.fedi_off': 'Nicht im Fediverse geteilt (nur Web-Player, Datei verborgen) — zum Teilen klicken',
-    'aaud.embed_player': 'Einbettbarer Player',
-    'aseo.mb_legend': 'MusicBrainz-Verknüpfung',
-    'aseo.mb_linked': 'Verknüpft mit',
-    'aseo.mb_unlink': 'Trennen',
-    'aseo.mb_open': 'Bei MusicBrainz ansehen',
-    'aseo.mb_pick': 'Das bin ich',
-    'aseo.mb_none': 'Nichts gefunden. Noch nicht dabei? Du kannst dich auf musicbrainz.org eintragen — das geht nur dort, nicht aus Klonkt heraus.',
-    'aseo.mb_busy': 'Suche läuft…',
-    'aseo.mb_fail': 'MusicBrainz ist gerade nicht erreichbar.',
-    'aseo.mb_hint': 'Hier verknüpfst du deine MusicBrainz-Künstler-ID mit deiner Domain, mit Rückweg-Validierung über deine "social networking"-Profilseite.',
-    'aseo.mb_search_label': 'Nach deinem Namen suchen',
-    'aseo.mb_search_hint': 'dein Künstlername oder deine MusicBrainz-ID, falls du sie kennst',
-    'aseo.mb_placeholder': 'Ozzy Osbourne',
-    'aseo.mb_search': 'Nachschlagen',
-    'aseo.mb_verified': 'Gegenseitig: die MusicBrainz-Seite verweist zurück auf diese Domain.',
-    'aseo.mb_unverified': 'Noch einseitig. Trage diese Domain auf deiner MusicBrainz-Seite unter "social networking" ein, dann ist die Verknüpfung von beiden Seiten bestätigt.',
-    'aseo.mb_checking': 'Rückweg wird geprüft…',
-    'aaud.embed_hint': 'Füge diesen Code auf deiner eigenen Website/deinem Blog ein, um deine Musik mit diesem Player einzubetten:',
-    'aaud.preview_player': 'Player-Vorschau öffnen',
-    'aaud.st_queued': 'Warten',
-    'aaud.st_uploading': 'Wird hochgeladen…',
-    'aaud.st_transcoding': 'Wird konvertiert…',
-    'aaud.st_done': 'Fertig',
-    'aaud.st_error': 'Fehler',
-    'aaud.err_unexpected': 'Unerwartete Serverantwort',
-    'aaud.failed': 'Fehlgeschlagen',
-    'aaud.copied': 'kopiert',
-    'aaud.new_track': 'Neuer Track',
-    'aaud.create_failed': 'Track konnte nicht erstellt werden',
-    'aaud.editor_not_loaded': 'Track-Editor nicht geladen',
-    'aaud.change_failed': 'Änderung fehlgeschlagen',
-    'astat.title': 'Statistiken',
-    'astat.intro': 'Cookiefrei gemessen — keine Tracking-Cookies, kein Zustimmungsbanner. Besucher werden pro Tag über einen täglich rotierenden, anonymen Hash gezählt (IP/Browser werden nicht gespeichert). Deine eigenen Admin-Besuche und bekannte Bots/Crawler werden nicht mitgezählt.',
-    'astat.your_ip': 'Deine IP', 'astat.ip_counted': 'wird mitgezählt.', 'astat.ip_not_counted': 'wird NICHT mitgezählt.', 'astat.ip_exclude': 'Meine Besuche nicht zählen', 'astat.ip_count': 'Meine Besuche zählen',
-    'astat.visitor_days': 'Besuchertage ({n}T)',
-    'astat.pageviews_days': 'Aufrufe ({n}T)',
-    'astat.plays_total': 'Wiedergaben (gesamt)',
-    'astat.postviews_total': 'Beitragsaufrufe (gesamt)',
-    'astat.alltime_pre': 'Gesamt:',
-    'astat.alltime_mid': 'Aufrufe',
-    'astat.alltime_post': 'Besuchertage.',
-    'astat.help_summary': 'Was bedeuten diese Zahlen?',
-    'astat.help_vd_term': 'Besuchertage',
-    'astat.help_vd_a': 'die Anzahl eindeutiger Besucher',
-    'astat.help_vd_em': 'pro Tag, zusammengezählt',
-    'astat.help_vd_b': '. Eine Person, die an 5 Tagen vorbeischaut = 5 Besuchertage. Ohne Cookies kann nicht über Tage hinweg gezählt werden, daher ist dies keine Anzahl eindeutiger Personen — die tatsächliche Personenzahl liegt (oft deutlich) niedriger.',
-    'astat.help_pv_term': 'Aufrufe',
-    'astat.help_pv': 'wie oft die Startseite/der Feed oder ein Beitrag geladen wurde (auch beim Klicken innerhalb der Site). Andere Seiten (Termine, Downloads, Links) werden hier nicht mitgezählt.',
-    'astat.help_plays_term': 'Wiedergaben',
-    'astat.help_plays': 'Gesamtzahl, wie oft ein Track gestartet wurde.',
-    'astat.help_postviews_term': 'Beitragsaufrufe',
-    'astat.help_postviews': 'gesamt über alle Beiträge zusammen.',
-    'astat.help_footer': 'Admin-Besuche und bekannte Bots/Crawler werden übersprungen. Die rohe IP wird niemals gespeichert. Gut für Trends; nimm absolute Zahlen mit Vorsicht.',
-    'astat.period': 'Zeitraum:',
-    'astat.last_n_days': 'Letzte {n} Tage',
-    'astat.lg_visitor_days': 'Besuchertage',
-    'astat.lg_pageviews': 'Aufrufe',
-    'astat.bar_title': '{day} — {pv} Aufrufe, {vd} Besuchertage',
-    'astat.top_posts': 'Beliebteste Beiträge',
-    'astat.no_views': 'Noch keine Aufrufe.',
-    'astat.most_played': 'Meistgehört',
-    'astat.no_plays': 'Noch keine Wiedergaben.',
-    'astat.sources': 'Quellen (woher Besucher kommen)',
-    'astat.linkbio_clicks': 'Link-in-Bio-Klicks',
-    'apl.title': 'Playlists',
-    'apl.tagline_pre': 'Kanonische Playlists. Bearbeite eine Playlist hier und die Änderungen wirken sich auf alle Beiträge aus, die sie verwenden, über',
-    'apl.tagline_post': '.',
-    'apl.new_playlist': 'Neue Playlist',
-    'apl.none': 'Noch keine Playlists.',
-    'apl.none_sub': 'Erstelle eine über die Schaltfläche oben oder über die 📃-Schaltfläche im Beitrags-Editor.',
-    'apl.pill_playlist': 'Playlist',
-    'apl.pill_album': 'Album', 'apl.pill_mixtape': 'Mixtape',
-    'apl.track': 'Track',
-    'apl.tracks': 'Tracks',
-    'apl.copy_click': 'Zum Kopieren klicken',
-    'apl.edit': 'Bearbeiten',
-    'apl.delete': 'Löschen',
-    'apl.copied': 'kopiert',
-    'apl.delete_confirm': 'Playlist "{title}" löschen? Beiträge, die diese Playlist einbetten, zeigen ab jetzt einen Platzhalter.',
-    'apl.delete_failed': 'Löschen fehlgeschlagen',
-    'ausr.back': 'Verwaltung',
-    'ausr.title': 'Benutzer',
-    'ausr.tagline_a': 'Benutzer, Rollen und Löschungen verwalten. Rolle',
-    'ausr.tagline_b': '= alles ansehen (inkl. Verwaltung), nichts ändern — praktisch für Demos.',
-    'ausr.empty': 'Keine Benutzer.',
-    'ausr.you': 'du',
-    'ausr.t_sites': 'Seiten',
-    'ausr.t_posts': 'Beiträge',
-    'ausr.t_joined': 'Registriert am',
-    'ausr.l_sites': 'Seiten',
-    'ausr.l_posts': 'Beiträge',
-    'ausr.l_joined': 'dabei seit',
-    'ausr.new_klonkt': 'Neues Klonkt für diesen Benutzer',
-    'ausr.new_klonkt_for': 'Neues Klonkt für {name}',
-    'ausr.role_label': 'Rolle',
-    'ausr.role_kijker': 'Betrachter',
-    'ausr.role_member': 'Mitglied',
-    'ausr.role_admin': 'Admin',
-    'ausr.role_god': 'God',
-    'ausr.delete': 'Löschen',
-    'ausr.del_warn': 'Dies löscht auch ihre Seite + {n} Beitrag/Beiträge.',
-    'ausr.del_confirm': 'Benutzer {name} löschen?',
-    'ausr.del_undo': 'Dies kann nicht rückgängig gemacht werden.',
-    'asit2.back': 'Verwaltung',
-    'asit2.title': 'Seiten',
-    'asit2.tagline': 'Alle Seiten dieser Installation verwalten.',
-    'asit2.new_site': 'Neue Seite',
-    'asit2.empty': 'Noch keine Seiten.',
-    'asit2.empty_sub': 'Erstelle eine über die Schaltfläche oben.',
-    'asit2.pill_primary': 'primär',
-    'asit2.pill_primary_title': 'Haupt-/Label-Seite dieser Installation',
-    'asit2.pill_public': 'öffentlich',
-    'asit2.pill_public_title': 'Öffentlich sichtbar',
-    'asit2.pill_private': 'privat',
-    'asit2.pill_private_title': 'Nicht öffentlich',
-    'asit2.pill_noindex': 'noindex',
-    'asit2.pill_noindex_title': 'Nicht von Suchmaschinen indexiert',
-    'asit2.by': 'von',
-    'asit2.t_posts': 'Anzahl Beiträge',
-    'asit2.l_posts': 'Beiträge',
-    'asit2.t_created': 'Erstellt am',
-    'asit2.l_created': 'erstellt',
-    'asit2.make_primary': 'Als primär festlegen',
-    'asit2.make_primary_title': 'Als primäre/Haupt-Seite festlegen',
-    'asit2.make_primary_confirm': 'Diese Seite als primäre/Haupt-Seite festlegen?',
-    'asit2.edit': 'Bearbeiten',
-    'asit2.delete': 'Löschen',
-    'asit2.delete_confirm': 'Seite löschen? Funktioniert nur, wenn keine Beiträge vorhanden sind.',
-    'acom.back': 'Verwaltung',
-    'acom.title': 'Kommentar-Moderation',
-    'acom.mode_for_site': 'Modus für diese Seite:',
-    'acom.mode_trust_a': 'Kommentare werden automatisch freigegeben. Wechsle zu',
-    'acom.mode_moderate_word': 'Moderieren',
-    'acom.site_settings': 'Seiteneinstellungen',
-    'acom.mode_trust_b': 'um sie in die Warteschlange zu stellen.',
-    'acom.mode_moderate_hint': 'Neue Kommentare müssen freigegeben werden, bevor sie bei Beiträgen erscheinen.',
-    'acom.pending': 'Ausstehend ({n})',
-    'acom.nothing_waiting': 'Nichts in der Warteschlange.',
-    'acom.reply': 'Antwort',
-    'acom.on': 'zu',
-    'acom.approve': 'Freigeben',
-    'acom.reject': 'Ablehnen',
-    'acom.recent': 'Letzte Entscheidungen',
-    'acom.nothing_yet': 'Noch nichts.',
-    'acir.title': 'Zirkel',
-    'acir.back_settings': 'Einstellungen',
-    'acir.circles': 'Zirkel',
-    'acir.settings': 'Einstellungen',
-    'acir.mode_off_1': 'Der Modus steht nicht auf',
-    'acir.mode_off_2': '. Schalte ihn unter',
-    'acir.mode_off_3': 'ein, um deinen Zirkel-Feed anzuzeigen auf',
-    'acir.mode_off_4': '. Du kannst unten aber schon Quellen vorbereiten.',
-    'acir.visibility_title': 'Meine Sichtbarkeit',
-    'acir.all_public': 'bereits öffentlichen',
-    'acir.visibility_help_1': 'Machst du bei Zirkeln mit? Damit werden deine',
-    'acir.visibility_help_2': 'Beiträge für andere Klonkt-Seiten über einen signierten Feed abrufbar',
-    'acir.visibility_help_3': 'Es ist eine Teilnahme-Entscheidung, kein Datenschutz-Schloss: deine Beiträge bleiben ohnehin öffentlich auf deiner Seite, auch wenn dies aus ist. Möchtest du etwas abschirmen, mach den Beitrag nicht-öffentlich.',
-    'acir.show_in_circles': 'Meine Seite in den Zirkeln anderer anzeigen',
-    'acir.save': 'Speichern',
-    'acir.add_title': 'Klonkt-Seite hinzufügen',
-    'acir.add_help': 'Füge die Basis-URL einer anderen Klonkt-Seite ein. Asymmetrisch: du zeigst sie, unabhängig davon, ob sie dich zeigen.',
-    'acir.url': 'URL',
-    'acir.label': 'Bezeichnung',
-    'acir.optional': 'optional',
-    'acir.name_auto': 'Der Name wird automatisch von der Seite übernommen — nur die URL ist nötig.',
-    'acir.label_ph': 'z. B. Joost Klein',
-    'acir.add': 'Hinzufügen',
-    'acir.in_circle': 'In meinem Zirkel ({n})',
-    'acir.no_sources': 'Noch keine Quellen. Füge oben eine hinzu.',
-    'acir.sync_all': 'Jetzt alle synchronisieren',
-    'acir.st_active': 'aktiv',
-    'acir.posts': 'Beiträge',
-    'acir.last': 'zuletzt',
-    'acir.st_mismatch': 'Versions-Unterschied',
-    'acir.mismatch_reason': 'Diese Seite läuft mit einer anderen Klonkt-Protokollversion — ein Update ist nötig, um zu föderieren.',
-    'acir.st_error': 'Fehler',
-    'acir.st_paused': 'pausiert',
-    'acir.refresh': 'Aktualisieren',
-    'acir.remove': 'Entfernen',
-    'acir.remove_confirm': 'Aus deinem Zirkel entfernen?',
-    'ashow.back_admin': 'Verwaltung',
-    'ashow.title': 'Termine',
-    'ashow.show_toggle': 'Termine auf der Seite anzeigen',
-    'ashow.show_toggle_hint': '(Termine-Button in der Leiste + die Termin-Seite)',
-    'ashow.save': 'Speichern',
-    'ashow.off': 'aus',
-    'ashow.off_notice_1': 'Die Termine stehen gerade auf',
-    'ashow.off_notice_2': '— Besucher sehen keinen Termine-Button und die Termin-Seite ist nicht erreichbar. Schalte sie ein, um deine Veranstaltungen anzuzeigen.',
-    'ashow.subscribers': 'Abonnent(en) für Veranstaltungs-Ankündigungen.',
-    'ashow.smtp_warn': '⚠ SMTP nicht eingerichtet — Veranstaltungen werden gespeichert, aber Benachrichtigungs-Mails können erst versendet werden, sobald du SMTP einträgst.',
-    'ashow.f_date': 'Datum',
-    'ashow.f_time': 'Uhrzeit (optional)',
-    'ashow.f_city': 'Ort',
-    'ashow.f_country': 'Land (optional)',
-    'ashow.f_venue': 'Location/Saal (optional)',
-    'ashow.f_ticket': 'Ticket-URL (optional)',
-    'ashow.f_notes': 'Notiz (optional)',
-    'ashow.f_notes_ph': 'Support: ...',
-    'ashow.notify_label': 'Abonnenten per E-Mail benachrichtigen',
-    'ashow.smtp_required': '(SMTP erforderlich)',
-    'ashow.add_event': '+ Veranstaltung hinzufügen',
-    'ashow.del_confirm': 'Veranstaltung löschen??',
-    'ashow.empty': 'Noch keine Veranstaltungen.',
-    'anews.title': 'Newsletter',
-    'anews.confirmed': 'bestätigt',
-    'anews.pending': 'ausstehend',
-    'anews.unsub': 'abgemeldet',
-    'anews.smtp_warn_1': '⚠ SMTP ist noch nicht eingerichtet. Anmeldungen werden zwar gesammelt, aber der Versand ist erst möglich, sobald du deine SMTP-Daten einträgst',
-    'anews.smtp_warn_2': 'in',
-    'anews.share': 'Anmeldelink zum Teilen:',
-    'anews.subject': 'Betreff',
-    'anews.subject_ph': 'Neue Single draußen!',
-    'anews.body': 'Nachricht',
-    'anews.body_ph': 'Schreibe dein Update…',
-    'anews.send_confirm': 'Newsletter an {n} bestätigte(n) Abonnent(en) senden?',
-    'anews.send_btn': 'An {n} Abonnent(en) senden',
-    'anews.sent_heading': 'Versendet',
-    'anews.recipients': 'Empfänger',
-    'aupd.title': 'Updates',
-    'aupd.changes_heading': 'Letzte Änderungen',
-    'aupd.back_admin': 'Verwaltung',
-    'aupd.version_heading': 'Version dieses Klonkt',
-    'aupd.app_version': 'App-Version',
-    'aupd.current': 'Aktuell',
-    'aupd.current_unknown': 'unbekannt (noch nicht über den Update-Button aktualisiert)',
-    'aupd.latest': 'Neueste',
-    'aupd.latest_failed': 'die neueste Version konnte nicht abgerufen werden',
-    'aupd.no_source': 'Keine Update-Quelle erreichbar',
-    'aupd.uptodate': 'Aktuell',
-    'aupd.update_available': 'Update verfügbar',
-    'aupd.behind_one': '{n} Commit zurück',
-    'aupd.behind_many': '{n} Commits zurück',
-    'aupd.run_confirm': 'Die Seite wird auf die neueste Version gebracht und kurz neu gestartet. Fortfahren?',
-    'aupd.redeploy': 'Erneut ausrollen',
-    'aupd.update_now': 'Jetzt aktualisieren',
-    'aupd.help': 'Das Aktualisieren holt den neuesten Code und startet diese Seite kurz neu (~10s). Lass dir Zeit — es geht nichts verloren (deine Beiträge, Einstellungen und dein Zirkel bleiben erhalten).',
-    'aupd.manual_hint': 'Aktualisiere von GitHub, indem du dies auf deinem Server ausführst:',
-    'aepk.title': 'Pressekit bearbeiten',
-    'aepk.saved': 'Pressekit gespeichert',
-    'aepk.back_admin': 'Verwaltung',
-    'aepk.view_epk': 'Pressekit ansehen',
-    'aepk.text_heading': 'Text',
-    'aepk.text_help': 'Das Pressekit (/pers) zeigt deinen Seitennamen + Foto, diese Bio und Kontakt sowie automatisch deine meistgehörten Titel und neuesten Beiträge. Lass die Bio leer, um den Seiten-Slogan zu verwenden; lass Kontakt leer, um nichts anzuzeigen (deine Login-E-Mail wird nie automatisch angezeigt).',
-    'aepk.bio_label': 'Presse-Bio',
-    'aepk.bio_ph': 'Kurze Beschreibung von dir/dem Projekt für Presse & Booker.',
-    'aepk.contact_label': 'Presse-Kontakt',
-    'aepk.contact_ph': 'z. B. presse@deinedomain.de oder ein Booking-Link',
-    'aepk.tracks_label': 'Titel im Pressekit',
-    'aepk.tracks_hint': '(wähle max. {n}; leer lassen für automatisch die Top {n} meistgehört)',
-    'aepk.no_tracks': 'Noch keine Titel — füge zuerst Audio unter Verwaltung → Audio hinzu.',
-    'aepk.untitled': '(ohne Titel)',
-    'aepk.save': 'Speichern',
-    'ahelp.back': 'Verwaltung',
-    'ahelp.title': 'Anleitung',
-    'ahelp.intro': 'Erklärung aller Funktionen. Tippe unten, um nach einem Thema oder einer Anleitung zu suchen.',
-    'ahelp.search_placeholder': 'Suchen… (z. B. \'Termine\', \'Foto\', \'Zirkel\', \'Downloads\')',
-    'ahelp.search_aria': 'In der Anleitung suchen',
-    'ahelp.premium': 'Premium',
-    'ahelp.empty': 'Keine Themen für deine Suche gefunden.',
-    'ahelp.s_newpost_h': 'Einen neuen Beitrag schreiben',
-    'ahelp.s_newpost_b': 'Verwaltung → <strong>Neuer Beitrag</strong>. Oben wählst du den <strong>Typ</strong> (Beitrag · Foto · Video · Audio) — der bestimmt die Eingaben darunter. Gib einen Titel ein und schreibe deinen Inhalt. Unten wählst du den Status: <em>Entwurf</em> (nicht sichtbar) oder <em>veröffentlicht</em>. Entwürfe stehen oben in deiner Verwaltungsübersicht, damit du sie wiederfindest.',
-    'ahelp.s_excerpt_h': 'Zusammenfassung & Zirkel-Vorschau',
-    'ahelp.s_excerpt_b': 'Das Feld <strong>Zusammenfassung & Zirkel-Vorschau</strong> (der Auszug) ist der kurze Vorschautext unter einem Beitrag in Listen sowie die Zusammenfassung, die andere Seiten anzeigen, wenn sie deinen Beitrag über einen <strong>Zirkel</strong> übernehmen. Lässt du es leer, wird automatisch der Anfang des Beitrags verwendet.',
-    'ahelp.s_pin_h': 'Beitrag anheften / Reihenfolge',
-    'ahelp.s_pin_b': 'Im Beitragseditor kannst du einen Beitrag mit einem Rang <strong>anheften</strong> (1 = ganz oben). Angeheftete Beiträge stehen in der Zeitleiste/im Raster ganz vorne, sortiert nach ihrem Rang. Rang leer oder 0 = nicht angeheftet.',
-    'ahelp.s_schedule_h': 'Veröffentlichung planen & nur für Freunde',
-    'ahelp.s_schedule_b': 'Im Editor kannst du ein <strong>Veröffentlichungsdatum</strong> in der Zukunft festlegen; der Beitrag erscheint dann automatisch zu diesem Zeitpunkt. Mit <strong>Nur für Freunde</strong> sehen nicht angemeldete Besucher nur einen Teaser + Login-Einladung; angemeldete Freunde sehen alles.',
-    'ahelp.s_images_h': 'Bilder in Beiträgen',
-    'ahelp.s_images_b': 'Bilder im Text und das Titelbild werden immer <strong>vollständig</strong> in voller Breite angezeigt (nicht zugeschnitten), in ihrer natürlichen Höhe.',
-    'ahelp.s_audio_h': 'Audio & Titel hinzufügen',
-    'ahelp.s_audio_b': 'Verwaltung → <strong>Audio</strong>. Lade eine Datei hoch oder füge einen <em>Nur-Link</em>-Titel hinzu (ohne Upload, nur „Öffnen in“-Links). Pro Titel gibst du Titel, Künstler, Cover und optional Album/Position an. In einem Beitrag zeigst du einen Titel mit dem Shortcode <code>[[track:id]]</code>, ein Album mit <code>[[album:Name]]</code>, eine Playlist mit <code>[[playlist:id]]</code>. <strong>Schneller:</strong> Wähle in einem Beitrag oben den Typ <em>Audio</em> und zieh die Datei direkt hinein — sie wird umgewandelt und sofort in den Beitrag eingefügt.',
-    'ahelp.s_credit_h': 'Credit, Lizenz & „Öffnen in“',
-    'ahelp.s_credit_b': 'Pro Titel kannst du einen <strong>Eigentümer/Credit</strong> (mit ©-Schaltfläche) und eine <strong>Lizenz</strong> festlegen — diese werden auch in die mp3-Metadaten geschrieben. Mit den <strong>Öffnen-in</strong>-Feldern (Spotify / YouTube / SoundCloud) erscheinen Schaltflächen, um den Titel auf diesen Plattformen zu öffnen.',
-    'ahelp.s_downloads_h': 'Downloads',
-    'ahelp.s_downloads_b': 'Markiere einen Titel in Verwaltung → Audio als <strong>herunterladbar</strong> (⬇-Schaltfläche). Besucher finden sie auf der Seite <strong>/downloads</strong> und hinterlassen ihre E-Mail-Adresse, um die Datei zu erhalten (sie kommt auf deine Mailingliste). Möchtest du Downloads prominent im Feed? Erstelle einen normalen Beitrag mit dem Slug <code>downloads</code> und hefte ihn an.',
-    'ahelp.s_albums_h': 'Alben & Playlists',
-    'ahelp.s_albums_b': 'Gib Titeln dasselbe <strong>Album</strong> + eine <strong>Position</strong>, um ein Album zu bilden. Playlists erstellst du in Verwaltung → <strong>Playlists</strong>. Beide zeigst du in einem Beitrag mit <code>[[album:Name]]</code> oder <code>[[playlist:id]]</code>.',
-    'ahelp.s_agenda_h': 'Termine / Veranstaltungen',
-    'ahelp.s_agenda_b': 'Verwaltung → <strong>Termine</strong>. Aktiviere oben <strong>„Termine auf der Seite anzeigen“</strong> — dann erscheint die Termine-Schaltfläche in der Leiste und die Terminseite ist erreichbar. Füge Veranstaltungen hinzu (Datum, Ort, Veranstaltungsort, Tickets). Besucher können sich (unabhängig vom Newsletter) für eine Benachrichtigung bei einer neuen Veranstaltung anmelden.',
-    'ahelp.s_presskit_h': 'Pressekit',
-    'ahelp.s_presskit_b': 'Eine teilbare Presseseite unter <strong>/pers</strong>. Bearbeite sie über die Schaltfläche <strong>✎ Bearbeiten</strong> auf der Seite selbst (nur du siehst sie). Lege eine kurze Presse-Bio + Kontakt fest und wähle <strong>bis zu 5 Titel</strong>, die angezeigt werden (oder lass es leer = automatisch die 5 meistgehörten).',
-    'ahelp.s_circles_h': 'Zirkel (Föderation)',
-    'ahelp.s_circles_b': 'Ein <strong>Zirkel</strong> ist dein eigener kuratierter Feed. Öffne <strong>Fediverse → Folge ich</strong>, folge Konten und <strong>hebe sie hervor</strong> (✨). Hervorgehobene Konten und Beiträge, die du <strong>teilst</strong> (🔁), erscheinen in deinem <strong>/cirkel</strong>-Feed — ein geteilter Beitrag bekommt ein Boost-Abzeichen. Teilst du einen Beitrag von jemandem, dem du nicht folgst, kommt er ebenfalls hinein. Das ist lokal: nichts wird automatisch ins Fediverse gesendet — das Teilen an deine eigenen Follower ist immer eine bewusste Aktion pro Beitrag.',
-    'ahelp.s_stats_h': 'Statistiken',
-    'ahelp.s_stats_b': 'Verwaltung → <strong>Statistiken</strong>. Cookiefrei gemessen. <strong>Besuchertage</strong> = eindeutige Besucher pro Tag, aufsummiert (keine Personenzahl). <strong>Aufrufe</strong> = Start-/Feed- und Beitragsaufrufe. Verwaltungsbesuche und Bots zählen nicht mit. Gut für Trends; absolute Zahlen mit Vorsicht genießen.',
-    'ahelp.s_newsletter_h': 'Newsletter',
-    'ahelp.s_newsletter_b': 'Verwaltung → <strong>Newsletter</strong>: Verfasse eine Nachricht und sende sie an deine bestätigten Abonnenten. Besucher melden sich über die Fußzeile oder <strong>/nieuwsbrief</strong> an. Zum Versenden muss E-Mail (SMTP) eingerichtet sein.',
-    'ahelp.s_linkbio_h': 'Link-in-Bio',
-    'ahelp.s_linkbio_b': 'Eine Linktree-ähnliche Seite unter <strong>/links</strong> mit deinen Profil-Links. Die Klicks pro Link siehst du in den Statistiken.',
-    'ahelp.s_embed_h': 'Einbettbarer Player',
-    'ahelp.s_embed_b': 'Verwaltung → Audio zeigt einen kopierbaren <code>&lt;iframe&gt;</code>-Code (<strong>/embed</strong>), mit dem du deinen Player auf einer anderen Website einbetten kannst.',
-    'ahelp.s_appearance_h': 'Erscheinungsbild (Theme, Foto, Akzent)',
-    'ahelp.s_appearance_b': 'Verwaltung → <strong>Erscheinungsbild</strong>: Lege deinen Seitennamen, Slogan, dein Profilfoto, deine Akzentfarbe und Farbpalette fest sowie die Standard-Feed-Ansicht (Zeitleiste oder Raster).',
-    'ahelp.s_tenancy_h': 'Solo- / Zirkel-Modus',
-    'ahelp.s_tenancy_b': 'Oben in <strong>Verwaltung → Einstellungen</strong> wählst du den Modus. <em>Solo</em> = ein eigenständiger Blog: kein Fediverse, keine Kommentare. <em>Zirkel</em> = deine Seite macht beim Fediverse (ActivityPub) mit: Leute können dir folgen und antworten, und du bekommst den Fediverse-Bereich + deinen Zirkel-Feed. Das Wechseln ist sicher — es wird nichts gelöscht.',
-    'ahelp.s_fedi_h': 'Fediverse (ActivityPub)',
-    'ahelp.s_fedi_b': 'Im Modus <strong>Zirkel</strong> macht deine Seite beim Fediverse (Mastodon usw.) mit. Öffne den <strong>Fediverse</strong>-Bereich über die Weltkugel in der Menüleiste (oder die Glocke für Benachrichtigungen). Fünf Tabs: <strong>News</strong> (Beiträge der Konten, denen du folgst — hier ⭐ liken und 🔁 teilen, nochmal klicken = rückgängig), <strong>Folge ich</strong> (Konten per @handle oder Profil-URL folgen und ✨ für deinen Zirkel hervorheben), <strong>Antworten</strong> (deine gesendeten Antworten + das <em>Interaktions-Bookmarklet</em> zum Ziehen in die Lesezeichenleiste, um von jedem Fediverse-Beitrag zu antworten), <strong>Benachrichtigungen</strong> (neue Follower, Likes, Boosts und Antworten auf deine Beiträge) und <strong>Blockieren</strong> (ein Konto oder eine ganze Domain blockieren). Unter jedem Beitrag zeigt "Aus dem Fediverse" eingehende Antworten, Likes und Boosts; als Eigentümer kannst du dort direkt antworten, liken oder teilen. Besucher nutzen den Button "Interact via the fediverse", um von ihrem eigenen Konto zu reagieren. Klicke auf dein Profilfoto für eine Profil-Zusammenfassung; Besucher finden dort auch einen "Über das Fediverse folgen"-Button. Deine Beiträge werden automatisch an deine Follower zugestellt.',
-    'ahelp.s_premium_h': 'Premium / Patreon',
-    'ahelp.s_premium_b': 'Premium-Funktionen (Statistiken, Termine, Downloads, Pressekit, Newsletter, Link-in-Bio, Embed) schaltest du in Verwaltung → Einstellungen frei, indem du Patreon verknüpfst ($16 lebenslang). Updates und die Kern-App bleiben immer kostenlos.',
-    'ahelp.s_password_h': 'Passwort vergessen / zurücksetzen',
-    'ahelp.s_password_b': 'Das Zurücksetzen läuft über die <strong>Kommandozeilen-Skripte</strong> auf dem Server. Führe im Projektordner <code>npm run reset-admin</code> aus: ohne Argument setzt das den God-User zurück und gibt das neue Passwort aus. Ein bestimmter Benutzer: <code>npm run reset-admin -- &lt;Benutzer|E-Mail&gt;</code>. Passwort selbst wählen (mindestens 8 Zeichen): <code>npm run reset-admin -- &lt;Benutzer|E-Mail&gt; &lt;Passwort&gt;</code>. Melde dich danach unter <strong>/auth/login</strong> an. Der Reset-Link per Mail unter <strong>/auth/reset-request</strong> funktioniert nur, wenn E-Mail (SMTP) eingerichtet ist; die Kommandozeile funktioniert immer.',
-    'ahelp.s_updates_h': 'Updates',
-    'ahelp.s_updates_b': 'Verwaltung → <strong>Updates</strong> (nur God) zeigt die aktuelle Version und ob eine neuere verfügbar ist. Mit „Jetzt aktualisieren“ holst du dir die neueste Version.',
-    'pedit.title_new': 'Neuer Beitrag',
-    'pedit.title_edit': 'Beitrag bearbeiten',
-    'pedit.f_title': 'Titel',
-    'pedit.f_slug': 'Slug (URL)',
-    'pedit.slug_placeholder': 'automatisch aus Titel, wenn leer',
-    'pedit.f_tags': 'Tags',
-    'pedit.tags_hint': '(kommagetrennt)',
-    'pedit.f_excerpt': 'Zusammenfassung & Zirkel-Vorschau',
-    'pedit.excerpt_hint': 'Wird auch als Zusammenfassung in <strong>Zirkeln</strong> verwendet (andere Seiten, die deinen Beitrag zeigen). Leer lassen = Anfang des Beitrags.',
-    'pedit.s_cover': 'Titelbild',
-    'pedit.f_cover_url': 'Titelbild-URL',
-    'pedit.cover_url_placeholder': '/media/…  oder https://…  (oder per Schaltfläche hochladen)',
-    'pedit.f_cover_alt': 'Alt-Text (Beschreibung)',
-    'pedit.cover_alt_placeholder': 'Beschreibe das Bild für Screenreader',
-    'pedit.f_language': 'Sprache',
-    'pedit.language_hint': 'für den Fediverse-Sprachfilter',
-    'pedit.cover_upload_btn': 'Neues Titelbild hochladen',
-    'pedit.s_content': 'Inhalt',
-    'pedit.content_hint': 'Bild ziehen zum Einfügen · Text markieren zum Formatieren',
-    'pedit.tb_done': 'Fertig',
-    'pedit.tb_done_title': 'Bearbeitung beenden',
-    'pedit.tb_bold': 'Fett',
-    'pedit.tb_bold_title': 'Fett (Strg+B)',
-    'pedit.tb_italic': 'Kursiv',
-    'pedit.tb_italic_title': 'Kursiv (Strg+I)',
-    'pedit.tb_underline': 'Unterstrichen',
-    'pedit.tb_h2': 'Überschrift',
-    'pedit.tb_h3': 'Zwischenüberschrift',
-    'pedit.tb_p': 'Absatz',
-    'pedit.tb_ul': 'Liste',
-    'pedit.tb_ol': 'Nummerierte Liste',
-    'pedit.tb_quote': 'Zitat',
-    'pedit.tb_link': 'Link',
-    'pedit.tb_link_title': 'Link (Strg+K)',
-    'pedit.tb_code': 'Code',
-    'pedit.tb_code_title': 'Code (inline)',
-    'pedit.tb_image': 'Bild',
-    'pedit.tb_image_title': 'Bild einfügen',
-    'pedit.tb_track': 'Track',
-    'pedit.tb_track_title': 'Track einfügen',
-    'pedit.tb_playlist': 'Playlist',
-    'pedit.tb_playlist_title': 'Playlist einfügen',
-    'pedit.tb_embed': 'Medien einbetten',
-    'pedit.tb_embed_title': 'Einbetten (YouTube, Spotify, SoundCloud, Vimeo…)',
-    'pedit.tb_clear': 'Formatierung entfernen',
-    'pedit.tb_fullscreen': 'Vollbild',
-    'pedit.editor_aria': 'Inhalt',
-    'pedit.editor_placeholder': 'Schreib los…',
-    'pedit.tap_to_edit': 'Zum Bearbeiten tippen',
-    'pedit.tap_to_write': 'Zum Schreiben tippen…',
-    'pedit.chars': 'Zeichen',
-    'pedit.s_publication': 'Veröffentlichung',
-    'pedit.f_status': 'Status',
-    'pedit.status_published': 'Veröffentlicht',
-    'pedit.status_draft': 'Entwurf',
-    'pedit.status_archived': 'Archiviert',
-    'pedit.f_type': 'Typ',
-    'pedit.type_post': 'Beitrag',
-    'pedit.type_foto': 'Foto',
-    'pedit.type_video': 'Video',
-    'pedit.type_audio': 'Audio',
-    'pedit.type_album': 'Album',
-    'pedit.type_playlist': 'Playlist', 'pedit.type_mixtape': 'Mixtape',
-    'pedit.s_type': 'Was für ein Beitrag?',
-    'pedit.audio_up_drop': 'Audio hierher ziehen oder klicken',
-    'pedit.audio_up_hint': 'mp3, m4a, ogg, flac, wav — wird automatisch umgewandelt und direkt in den Beitrag eingefügt. Details später über den Track ändern.',
-    'pedit.audio_up_busy': 'Wird hochgeladen…',
-    'pedit.audio_up_done': 'Zum Beitrag hinzugefügt',
-    'pedit.audio_up_fail': 'Fehlgeschlagen',
-    'pedit.video_up_title': 'Video hinzufügen',
-    'pedit.video_up_ph': 'Video-URL einfügen (YouTube, Vimeo…)',
-    'pedit.video_up_btn': 'Einfügen',
-    'pedit.foto_up_hint': 'Lege dein Foto unten als Titelbild fest oder füge Fotos im Text über die Bild-Schaltfläche in der Symbolleiste ein.',
-    'pedit.pin_label': 'Oben anheften',
-    'pedit.pin_up': 'Nach oben',
-    'pedit.pin_down': 'Nach unten',
-    'pedit.pin_top': 'ganz oben',
-    'pedit.pin_nth_suffix': '. von oben',
-    'pedit.noindex_label': 'noindex (vor Suchmaschinen verbergen)', 'pedit.nsfw_label': 'NSFW / sensibler Inhalt', 'pedit.fedi_audio_label': 'Audio offen im Fediverse teilen (spielt inline in Apps; Datei herunterladbar)', 'pedit.fedi_audio_oneway': 'Achtung: Öffnen ist dauerhaft', 'pedit.fedi_audio_locked': 'Dieses Audio wurde offen im Fediverse geteilt. Das ist dauerhaft — die Datei ist bereits verbreitet.', 'pedit.nsfw_cw_ph': 'Warntext (optional, Standard: Sensibler Inhalt)', 'post.nsfw_warning': 'Sensibler Inhalt', 'post.nsfw_show': 'Anzeigen', 'post.share': 'Teilen', 'post.share_copied': 'Link kopiert ✓', 'pedit.poll_label': 'Umfrage hinzufügen', 'pedit.poll_locked': 'Es wurde bereits abgestimmt — die Optionen lassen sich nicht mehr ändern.', 'pedit.poll_option_ph': 'Option', 'pedit.poll_add': 'Option hinzufügen', 'pedit.poll_remove': 'Option entfernen', 'pedit.poll_multiple': 'Mehrfachauswahl erlauben', 'pedit.poll_duration': 'Laufzeit', 'pedit.poll_dur_5m': '5 Minuten', 'pedit.poll_dur_30m': '30 Minuten', 'pedit.poll_dur_1h': '1 Stunde', 'pedit.poll_dur_6h': '6 Stunden', 'pedit.poll_dur_12h': '12 Stunden', 'pedit.poll_dur_1d': '1 Tag', 'pedit.poll_dur_3d': '3 Tage', 'pedit.poll_dur_7d': '7 Tage',
-    'pedit.fan_only_label': 'Nur für Freunde',
-    'pedit.schedule_label': 'Veröffentlichung planen',
-    'pedit.schedule_hint': 'Wenn aus, geht dein Beitrag sofort live. An = wähle unten, wann er erscheint.',
-    'pedit.publish_at_label': 'Datum & Uhrzeit',
-    'pedit.scheduled_for': 'Geplant für {d}',
-    'pedit.scheduled_prefix': 'Geplant für',
-    'pedit.cancel': 'Abbrechen',
-    'pedit.publish': 'Veröffentlichen',
-    'pedit.save': 'Speichern',
-    'pedit.js_link_prompt': 'Link-URL (https://… oder /pfad)',
-    'pedit.js_uploading': 'Wird hochgeladen…',
-    'pedit.js_uploaded': 'Hochgeladen',
-    'pedit.js_inserted': 'Eingefügt',
-    'pedit.js_failed': 'Fehlgeschlagen',
-    'pedit.js_embed_prompt': 'Füge eine Medien-URL zum Einbetten ein (YouTube, Spotify, SoundCloud, Vimeo, Apple Music, Bandcamp):',
-    'pedit.js_embed_invalid': 'Gib eine vollständige URL ein (https://…).',
-    'pedit.js_no_tracks_found': 'Keine Tracks gefunden für',
-    'pedit.js_no_tracks_yet': 'Noch keine Tracks. Lade welche über Verwaltung dann Audio hoch.',
-    'pedit.js_tracks_loading': 'Tracks werden geladen…',
-    'pedit.js_tracks_load_fail': 'Tracks konnten nicht geladen werden',
-    'pedit.js_playlist_editor_missing': 'Playlist-Editor nicht geladen',
-    'pedit.js_playlist_existing': 'Vorhandene Playlists:',
-    'pedit.js_playlist_choose': 'Wähle eine Nummer zum Einfügen, leer = neue erstellen:',
-    'pedit.chip_track': 'Track',
-    'pedit.chip_album': 'Album:',
-    'pedit.chip_playlist': 'Playlist:',
-    'pedit.tp_title': 'Track einfügen',
-    'pedit.tp_close': 'Schließen',
-    'pedit.tp_search_placeholder': 'Nach Titel oder Künstler suchen…',
-    'pedit.tp_list_aria': 'Tracks',
-    'imed.title': 'Bild bearbeiten',
-    'imed.rotate_left': 'Nach links drehen', 'imed.rotate_right': 'Nach rechts drehen',
-    'imed.flip_h': 'Horizontal spiegeln', 'imed.flip_v': 'Vertikal spiegeln',
-    'imed.zoom_in': 'Vergrößern', 'imed.zoom_out': 'Verkleinern', 'imed.reset': 'Zurücksetzen',
-    'imed.cancel': 'Abbrechen', 'imed.apply': 'Übernehmen',
-    'acct.back_home': 'Zurück zur Startseite',
-    'acct.title': 'Konto',
-    'acct.subtitle': 'Profil und Avatar.', 'acct.oauth_apps': 'Verbundene Apps', 'acct.oauth_hint': 'Apps, denen du \u00fcber OAuth Zugriff auf dein Konto gegeben hast. Widerrufe, was du nicht mehr vertraust oder nutzt.', 'acct.oauth_none': 'Noch keine Apps verbunden.', 'acct.oauth_unknown_app': 'Unbekannte App', 'acct.oauth_last_used': 'zuletzt genutzt', 'acct.oauth_never': 'nie', 'acct.oauth_revoke': 'Widerrufen', 'acct.oauth_revoked': 'App-Zugriff widerrufen.', 'acct.oauth_revoke_none': 'Dieser Zugriff bestand nicht mehr.',
-    'acct.viewer_mode': 'Betrachter-Modus',
-    'acct.viewer_note': 'Dies ist ein Demo-Konto. Du kannst alles ansehen, aber nichts ändern — auch kein Foto oder keine Bio.',
-    'acct.profile': 'Profil',
-    'acct.avatar_change': 'Zum Ändern deines Fotos klicken',
-    'acct.member_since': 'Mitglied seit', 'acct.photo_site_hint': 'Dein Profilbild legst du in den <a href="/admin">Site-Einstellungen</a> fest — ein Bild für überall.',
-    'acct.avatar_remove': 'Foto entfernen',
-    'acct.username': 'Benutzername',
-    'acct.email': 'E-Mail-Adresse',
-    'acct.email_ph': 'du@email.de',
-    'acct.bio': 'Bio',
-    'acct.bio_ph': 'Eine kurze Zeile über dich',
-    'acct.bio_empty': 'Keine Bio',
-    'acct.save': 'Speichern',
-    'acct.site': 'Seite',
-    'acct.site_name': 'Seitenname',
-    'acct.site_name_hint': '— wird in der Kopfzeile deiner Seite angezeigt',
-    'acct.tagline': 'Slogan',
-    'acct.tagline_hint': '— kurzer Einzeiler (optional)',
-    'acct.site_save': 'Seite speichern',
-    'acct.password_change': 'Passwort ändern',
-    'acct.password_current': 'Aktuelles Passwort',
-    'acct.password_new': 'Neues Passwort',
-    'acct.password_min': '(mind. 8 Zeichen)',
-    'acct.password_confirm': 'Neues Passwort bestätigen',
-    'acct.login': 'Anmelden',
-    'acct.login_google_only': 'Dieses Konto hat kein Passwort ({email}). Nutze "Passwort vergessen", um eines festzulegen.',
-    'news.this_artist': 'diese:r Künstler:in',
-    'news.form_title': 'Bleib auf dem Laufenden',
-    'news.form_sub_before': 'Abonniere den Newsletter von ',
-    'news.form_sub_after': ' — neue Musik, Konzerte und Updates, direkt in dein Postfach. Abmelden ist jederzeit mit einem Klick möglich.',
-    'news.email_ph': 'du@email.de',
-    'news.subscribe': 'Abonnieren',
-    'news.check_title': 'Fast geschafft ✉',
-    'news.check_sub_before': 'Wir haben eine Bestätigungs-E-Mail gesendet an ',
-    'news.check_sub_after': '. Klicke auf den Link in dieser E-Mail, um dein Abo zu bestätigen.',
-    'news.your_address': 'deine Adresse',
-    'news.done_title': 'Du bist angemeldet ✓',
-    'news.done_sub_before': 'Danke — du stehst auf der Liste von ',
-    'news.done_sub_after': '.',
-    'news.confirmed_title': 'Abo bestätigt ✓',
-    'news.confirmed_sub_before': 'Super! Du erhältst ab jetzt den Newsletter von ',
-    'news.confirmed_sub_after': '.',
-    'news.unsubbed_title': 'Abgemeldet',
-    'news.unsubbed_sub': 'Du wurdest abgemeldet. Du erhältst keine Newsletter mehr. Anders überlegt? Du kannst dich jederzeit wieder anmelden.',
-    'news.invalid_title': 'Ungültige E-Mail-Adresse',
-    'news.invalid_sub': 'Bitte überprüfe deine Adresse und versuche es erneut.',
-    'news.back': '← Zurück',
-    'news.smtperror_title': 'Hat gerade nicht geklappt',
-    'news.smtperror_sub': 'Die Bestätigungs-E-Mail konnte nicht gesendet werden. Bitte versuche es später noch einmal.',
-    'news.badtoken_title': 'Link ungültig oder abgelaufen',
-    'news.badtoken_sub': 'Dieser Link funktioniert nicht mehr. Melde dich bei Bedarf erneut an.',
-    'news.to_subscribe': 'Zur Anmeldung',
-    'news.error_title': 'Etwas ist schiefgelaufen',
-    'news.error_sub': 'Bitte versuche es später noch einmal.',
-    'dl.ready_title': 'Danke! ⬇',
-    'dl.ready_sub': 'Dein Download sollte jetzt starten. Passiert nichts?',
-    'dl.manual': 'Download manuell starten',
-    'dl.download_btn': 'Download',
-    'dl.capture_sub': 'Hinterlasse deine E-Mail-Adresse und du erhältst die Datei. Du kommst damit auch auf die Newsletter-Liste — abmelden ist jederzeit möglich.',
-    'dl.email_ph': 'du@email.de',
-    'epk.kicker': 'Pressekit',
-    'epk.edit': 'Bearbeiten',
-    'epk.contact_booking': 'Kontakt / Buchung',
-    'epk.view_site': 'Zur Seite →',
-    'epk.press_photo': 'Pressefoto',
-    'epk.most_played': 'Meistgehört',
-    'epk.recent': 'Aktuell',
-    'fgate.title': 'Nur für Freunde',
-    'fgate.sub': 'Dieser Beitrag ist für alle, die dieser Seite folgen.',
-    'owa.title': 'Bei einer anderen Seite anmelden', 'owa.sub': 'Diese Seite bittet deinen Server zu bestätigen, wer du bist. Wenn du fortfährst, kennt sie deine Adresse.', 'owa.as': 'Du meldest dich an als', 'owa.choose': 'Mit welcher deiner Seiten?', 'owa.go': 'Ja, anmelden', 'owa.cancel': 'Nein, zurück', 'owa.fine': 'Es geht kein Passwort an diese Seite. Nur deine Adresse, und nur wenn du hier ja sagst.', 'fgate.owa_label': 'Mit deiner eigenen Fediverse-Adresse anmelden', 'fgate.owa_go': 'Weiter', 'fgate.owa_hint': 'Dein eigener Server bestätigt, wer du bist. Kein Konto und kein Passwort hier — wenn du dieser Seite folgst, bist du drin.', 'fgate.owa_failed': 'Diese Adresse war nicht erreichbar. Stimmt die Schreibweise?', 'fgate.login': 'Anmelden / Registrieren', 'read.open': 'Antworten und Reaktionen',
-    'lbio.empty': 'Noch keine Links eingerichtet.',
-    'lbio.back_to_site': 'zurück zur Seite',
-    'myst.overview': 'Übersicht',
-    'myst.title': 'Mein Klonkt Hub',
-    'myst.quick_links_aria': 'Verwaltungs-Schnellzugriffe',
-    'myst.new_post': 'Neuer Beitrag',
-    'myst.appearance': 'Erscheinungsbild',
-    'myst.comments': 'Kommentare',
-    'myst.view_site': 'Meine Seite ansehen',
-    'myst.account': 'Konto',
-    'myst.posts': 'Beiträge',
-    'myst.published': 'Veröffentlicht',
-    'myst.draft_count_one': '{n} Entwurf',
-    'myst.draft_count_many': '{n} Entwürfe',
-    'myst.draft_badge': 'Entwurf',
-    'myst.untitled': '(ohne Titel)',
-    'myst.edit': 'Bearbeiten',
-    'myst.view': 'Ansehen',
-    'arst.title': 'Passwort zurücksetzen',
-    'arst.request_new': 'Neuen Reset-Link anfordern',
-    'arst.back_login': 'Zurück zur Anmeldung',
-    'arst.set_for': 'Lege ein neues Passwort für {username} fest.',
-    'arst.new_pw': 'Neues Passwort (mind. 8 Zeichen)',
-    'arst.confirm_pw': 'Neues Passwort bestätigen',
-    'arst.submit': 'Passwort festlegen',
-    'arrq.title': 'Passwort zurücksetzen',
-    'arrq.sent': 'Falls für diese E-Mail-Adresse ein Konto besteht, wurde ein Reset-Link gesendet.',
-    'arrq.no_mailserver': '<strong>Kein Mailserver eingerichtet</strong> — Reset-Link unten.',
-    'arrq.no_mail_cli': 'Keine E-Mail eingerichtet? Der Administrator kann auch <code>npm run reset-admin</code> auf dem Server ausführen.',
-    'arrq.back_login': '← Zurück zur Anmeldung',
-    'arrq.tagline': 'Gib deine E-Mail-Adresse ein; wir senden dir einen Reset-Link.',
-    'arrq.email': 'E-Mail',
-    'arrq.submit': 'Reset-Link senden',
-    'adir.home': 'Startseite',
-    'adir.logout': 'Abmelden',
-    'adir.login': 'Anmelden',
-    'adir.title': 'Mitglieder',
-    'adir.count_one': '{n} Klonkt',
-    'adir.count_many': "{n} Klonkt's",
-    'adir.search_ph': 'Nach Namen suchen…',
-    'adir.search_aria': 'Mitglieder suchen',
-    'adir.search_btn': 'Suchen',
-    'adir.clear': 'Löschen',
-    'adir.empty_q': 'Keine Mitglieder gefunden für „{q}".',
-    'adir.empty': 'Noch keine Mitglieder.',
-    'adir.posts_one': '{n} Beitrag',
-    'adir.posts_many': '{n} Beiträge',
-    'adir.pager_aria': 'Seitennummerierung',
-    'adir.prev': '← Zurück',
-    'adir.page_info': 'Seite {page} von {pages}',
-    'adir.next': 'Weiter →',
-    'pusr.post_one': 'Beitrag',
-    'pusr.post_many': 'Beiträge',
-    'pusr.on_this_site': 'auf dieser Seite',
-    'pusr.total': '{n} insgesamt',
-    'pusr.joined': 'dabei seit {date}',
-    'pusr.send_dm': 'DM senden',
-    'pusr.empty': 'Noch keine Beiträge auf dieser Seite.',
-    'pusr.posts_heading': 'Beiträge',
-    'pusr.untitled': '(ohne Titel)',
-    'phub.main_badge': 'Hauptseite',
-    'phub.view_page': 'Seite ansehen',
-    'phub.members': 'Mitglieder',
-    'phub.post_one': 'Beitrag',
-    'phub.post_many': 'Beiträge',
-    'phub.all_members': 'Alle {n} Mitglieder',
-    'phub.latest_posts': 'Neueste Beiträge unserer Mitglieder.',
-    'cfeed.title': 'Zirkel',
-    'cfeed.sub': 'Was bei den anderen Seiten in meinem Zirkel passiert.',
-    'cfeed.count': '{n} Seiten in meinem Zirkel',
-    'cfeed.empty': 'Noch nichts in meinem Zirkel.',
-    'cfeed.close': 'Schließen',
-    'cfeed.grid_view': 'Rasteransicht',
-    'cpost.back': 'Zirkel',
-    'cpost.via': 'via',
-    'cpost.read_more': 'Weiterlesen bei {source}',
-    'vblk.title': 'Betrachter-Modus',
-    'vblk.text_before': 'Dies ist ein schreibgeschütztes Konto. Du kannst alles ansehen, aber',
-    'vblk.text_strong': 'nichts ändern',
-    'vblk.text_after': '— Speichern, Hochladen, Löschen und Kommentieren sind deaktiviert.',
-    'vblk.back': 'Zurück',
-    'vblk.to_home': 'Zur Hauptseite',
-    'phome.moved_lead': 'Dieses Konto ist umgezogen. Du findest mich jetzt hier:',
-    'phome.moved_hint': 'Folgst du mir schon? Dann zieht dein Server dich wahrscheinlich automatisch mit. Falls nicht: folge der neuen Adresse.',
-    'phome.empty_title': 'Hier ist es noch still.',
-    'phome.empty_sub': 'Noch keine Beiträge. Spannend.',
-    'phome.write_first': 'Schreibe deinen ersten Beitrag',
-    'phome.grid_view': 'Rasteransicht',
-    'fav.title': 'Favoriten',
-    'fav.sub': 'Beiträge, die dir gefallen haben. Tippe auf das ♥ eines Beitrags, um ihn hier zu speichern.',
-    'fav.empty': 'Du hast noch keine Favoriten. Öffne einen Beitrag und tippe auf das Herz ♡.',
-    'chlog.back': 'Zurück',
-    'chlog.title': 'Änderungen',
-    'chlog.app_version': 'App-Version',
-    'chlog.fed_proto': 'Föderations-Proto',
-    'chlog.manage_updates': 'Updates verwalten',
-    'e404.title': 'Seite nicht gefunden',
-    'e404.sub': 'Diese Seite existiert nicht (mehr). Vielleicht ist der Link veraltet oder falsch eingegeben.',
-    'e404.home': 'Zur Startseite',
-    'e404.archive': 'Archiv',
-    'ptype.eyebrow': 'Typ',
-    'ptype.count_one': '{n} Beitrag',
-    'ptype.count_many': '{n} Beiträge',
-    'ptype.empty': 'Noch keine Beiträge dieses Typs.',
-    'ptype.untitled': '(ohne Titel)',
-    'ptag.eyebrow': 'Tag',
-    'ptag.count_one': '{n} Beitrag',
-    'ptag.count_many': '{n} Beiträge',
-    'ptag.empty': 'Noch keine Beiträge mit diesem Tag.',
-    'ptag.untitled': '(ohne Titel)',
-    'parch.back': 'Zurück',
-    'parch.title': 'Archiv',
-    'parch.count_one': '{n} Beitrag',
-    'parch.count_many': '{n} Beiträge',
-    'parch.empty': 'Noch keine Beiträge.',
-    'prin.tagline': 'Deine Direktnachrichten auf dieser Seite.',
-    'prin.empty': 'Noch keine Unterhaltungen. Öffne das Profil einer Person und klicke auf „DM senden“, um eine zu beginnen.',
-    'prin.empty_conv': 'Leere Unterhaltung',
-    'prcv.back_aria': 'Zurück zum Posteingang',
-    'prcv.inbox': 'Posteingang',
-    'prcv.unknown': 'Unbekannt',
-    'prcv.view_profile': 'Profil ansehen',
-    'prcv.placeholder': 'Nachricht…',
-    'prcv.send': 'Senden',
-    'acct.lang_label': 'Sprache',
-    'acct.lang_hint': '— deine persönliche Interface-Sprache; reist mit dir über Geräte und Sitzungen.',
-    'aset.default_lang': 'Standardsprache für Besucher',
-    'aset.default_lang_hint': 'Was neue Besucher sehen, bevor sie selbst eine Sprache wählen. Ein angemeldeter Benutzer mit eigener Voreinstellung sieht diese.',
-    'aset.default_lang_auto': 'Automatisch (Browsersprache)',
-    'aset.timezone': 'Zeitzone',
-    'aset.timezone_hint': 'Die Zeitzone, in der Datumsangaben und geplante Veröffentlichungen angezeigt und gespeichert werden. Automatisch = Server-Standard (UTC).',
-    'aset.timezone_auto': 'Automatisch (UTC)',
-  },
-};
-
-export function t(lang, key, vars) {
-  const l = SUPPORTED.includes(lang) ? lang : 'nl';
-  let s = (DICT[l] && DICT[l][key]);
-  if (s === undefined) s = (DICT.nl[key] !== undefined ? DICT.nl[key] : key);
-  if (vars) for (const k in vars) s = s.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
-  return s;
-}
-
-// Bepaal de taal voor dit request: expliciete sessie-keuze → instance-standaard
-// (env KLONKT_DEFAULT_LANG, bv. 'de' voor een Duitse site) → browser-taal → nl.
-export function resolveLang(req, opts = {}) {
-  const s = req && req.session && req.session.lang;
-  if (s && SUPPORTED.includes(s)) return s;             // bezoeker koos zelf (deze sessie)
-  const u = (opts.userLang || '').toLowerCase();
-  if (SUPPORTED.includes(u)) return u;                  // ingelogde gebruiker: eigen voorkeur
-  const d = (opts.defaultLang || '').toLowerCase();
-  if (SUPPORTED.includes(d)) return d;                  // admin-ingestelde standaard (Beheer/DB)
-  const envDefault = (process.env.KLONKT_DEFAULT_LANG || '').toLowerCase();
-  if (SUPPORTED.includes(envDefault)) return envDefault; // per-instance standaard (env)
-  const al = ((req && req.headers && req.headers['accept-language']) || '').toLowerCase();
-  const first = al.split(',')[0].trim().slice(0, 2);
-  if (SUPPORTED.includes(first)) return first;          // browser-voorkeur
-  return 'nl';
-}
Index: src/services/music/index.js
===================================================================
--- src/services/music/index.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,883 +1,0 @@
-/**
- * De muziekkant van ActivityPub: een track als AS2-object, de collecties
- * eromheen, en welke post hem uitbrengt.
- *
- * Waarom een eigen map (shaer-drc): ActivityPubService was 6400 regels over
- * tweeentwintig onderwerpen. Dit is het eerste onderwerp dat er als geheel uit
- * kan, en het is meteen het onderwerp dat gaat GROEIEN -- de typering uit
- * shaer-cyg (playlist versus album, afgeleid uit wat er in de post staat) landt
- * hier straks.
- *
- * De regel die guardianship al aanhoudt geldt hier ook: deze map importeert
- * alleen db en ap-core, en NOOIT terug uit ActivityPubService.
- */
-
-import db, { isoSql } from '../../config/database.js';
-import { AP_CONTEXT, PUBLIC, actorId, noteId, safeUrl, guessMediaType, buildHashtagList, pagedCollection, isMbid } from '../ap-core.js';
-import { afleidenUitInsluitingen, ingeslotenPlaylists, SOORTEN } from '../../assets/js/shared/post-music-type.js';
-// De luisteraars horen bij de muziekkant; hier doorgegeven zodat
-// ActivityPubService niet in een submap hoeft te grijpen.
-export * as luisteraars from './luisteraars.js';
-
-// m.size hoort erbij voor de RSS-enclosure: die eist een lengte in bytes.
-export const TRACK_KOLOMMEN = `t.id, t.title, t.artist, t.duration, t.cover_url, t.created_at,
-     t.position, t.license,
-     m.filename, m.storage_path, m.mime_type, m.size`;
-
-/**
- * `alles` net als bij siteOpenTracks (FEP-1580): zonder die tak krijgt de
- * instantie waar je naartoe verhuist een playlist met gaten erin, want alleen
- * de opengezette nummers zitten erin. Een halve plaat is geen plaat.
- */
-export function playlistOpenTracks(playlistId, { alles = false } = {}) {
-  return db.prepare(
-    `SELECT ${TRACK_KOLOMMEN}
-     FROM playlist_tracks pt
-     JOIN audio_tracks t ON t.id = pt.track_id
-     JOIN media m ON m.id = t.media_id
-     WHERE pt.playlist_id = ?${alles ? '' : ' AND t.fedi_open = 1'}
-     ORDER BY pt.position`
-  ).all(playlistId);
-}
-
-/**
- * Alle tracks die deze site aan de federatie heeft opengezet (shaer-0nh, stap 3).
- *
- * Dit is de KANONIEKE plek, niet de playlist: een playlist is een keuze, dit is
- * wat de artiest heeft uitgebracht. Een track die in geen enkele playlist zit
- * was tot nu toe onzichtbaar voor de federatie -- die staat hier wel.
- */
-/**
- * `alles` bestaat voor FEP-1580. Bij een verhuizing behandelt de bron een
- * ondertekend verzoek van de DOEL-actor als zichzelf, en dat geldt hier net zo
- * goed als bij de outbox. Zonder deze tak neemt een verhuizing alleen je
- * opengezette nummers mee en blijft je hele gesloten bibliotheek achter op een
- * domein dat je gaat opzeggen. De poort blijft verder dicht: alleen die ene
- * actor, en alleen omdat moveAccount() een terugverwijzing eiste voordat
- * moved_to er kwam te staan.
- */
-export function siteOpenTracks(siteId, { alles = false } = {}) {
-  return db.prepare(
-    `SELECT ${TRACK_KOLOMMEN}
-     FROM audio_tracks t JOIN media m ON m.id = t.media_id
-     WHERE t.site_id = ?${alles ? '' : ' AND t.fedi_open = 1'}
-     ORDER BY t.position, t.created_at, t.id`
-  ).all(siteId);
-}
-
-export function openTrack(siteId, trackId) {
-  return db.prepare(
-    `SELECT ${TRACK_KOLOMMEN}
-     FROM audio_tracks t JOIN media m ON m.id = t.media_id
-     WHERE t.site_id = ? AND t.id = ? AND t.fedi_open = 1`
-  ).get(siteId, trackId);
-}
-
-/**
- * Eén track als AS2 `Audio`, met een EIGEN id (shaer-0nh, stap 3).
- *
- * Waarom dat id het verschil maakt: zonder id is een track een naamloze bijlage
- * die alleen bestaat zolang je het omhullende object vasthoudt. Met id is het
- * een ding waar je naar kunt wijzen, dat je los kunt ophalen, en dat in twee
- * playlists hetzelfde ding is. Funkwhale adresseert zijn Audio-objecten
- * precies zo, per stuk, in Create en Delete.
- *
- * `url` is een Link-ARRAY, net als bij Funkwhale en net als wat onze eigen
- * inbox sinds bdcb3a3 verwacht: de mediaType hoort bij de link, niet bij het
- * object. Er zit GEEN text/html-link in: Klonkt heeft geen trackpagina -- een
- * track wordt getoond binnen een post, en een post over vijf nummers is niet de
- * pagina van dit ene nummer. Liever geen link dan een link die iets anders
- * belooft.
- */
-/**
- * Bij welke post hoort een track? (shaer-0nh)
- *
- * Een track staat nooit los in Klonkt: hij wordt getoond BINNEN een post, via
- * een van drie insluitingen in posts.content. Die relatie stond alleen in die
- * tekst en nergens op de draad -- waardoor Shaer, dat zijn feed uit de outbox
- * bouwt, sinds fb22f78 losse Audio-kaarten kreeg zonder inhoud.
- *
- * ALLES IN EEN ZOEKOPDRACHT, niet per track. De collectie loopt over elke open
- * track, en drie LIKE-scans per stuk wordt bij tweehonderd nummers zeshonderd
- * scans. Nu is het er een, en de map gaat mee als optie.
- *
- * De rang bepaalt welke post wint als er meerdere zijn: rechtstreeks ingesloten
- * is specifieker dan via een playlist, en die weer specifieker dan via een
- * albumnaam. Bij gelijke rang de nieuwste post -- dat is waar iemand hem het
- * laatst heeft uitgebracht.
- */
-export function trackHostPosts(siteId) {
-  const rijen = db.prepare(`
-    SELECT tid, post_id, post_slug, rang, wanneer FROM (
-      SELECT t.id AS tid, p.id AS post_id, p.slug AS post_slug, 1 AS rang,
-             COALESCE(p.published_at, p.created_at) AS wanneer
-        FROM audio_tracks t
-        JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
-                    AND p.content LIKE '%[[track:' || t.id || ']]%'
-       WHERE t.site_id = ? AND t.fedi_open = 1
-      UNION ALL
-      SELECT t.id, p.id, p.slug, 2, COALESCE(p.published_at, p.created_at)
-        FROM playlist_tracks pt
-        JOIN audio_tracks t ON t.id = pt.track_id
-        JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
-                    AND p.content LIKE '%[[playlist:' || pt.playlist_id || ']]%'
-       WHERE t.site_id = ? AND t.fedi_open = 1
-      UNION ALL
-      SELECT t.id, p.id, p.slug, 3, COALESCE(p.published_at, p.created_at)
-        FROM audio_tracks t
-        JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
-                    AND p.content LIKE '%[[album:' || t.album || ']]%'
-       WHERE t.site_id = ? AND t.fedi_open = 1 AND t.album IS NOT NULL AND t.album <> ''
-    ) ORDER BY rang, wanneer DESC
-  `).all(siteId, siteId, siteId);
-  const uit = new Map();
-  for (const r of rijen) if (!uit.has(r.tid)) uit.set(r.tid, { id: r.post_id, slug: r.post_slug });
-  return uit;
-}
-
-/**
- * De artiest-credit, gedeeld door track en album (shaer-3f8a / shaer-756s).
- *
- * De ENTITEIT is de site-actor: een echt, opvraagbaar adres. De credittekst --
- * de artiestkolom van de track of van de uitgave -- gaat naar `credit`, want
- * daar verwacht hun model hem. Er een id per artiestnaam van maken zou
- * identiteit uit een string zijn, en dat is de fout die we bij albums juist
- * vermijden.
- *
- * Eén functie voor beide, zodat een track en het album waar hij op staat nooit
- * een verschillende artiest kunnen krijgen door twee keer hetzelfde te bouwen.
- */
-function artistCredit(base, site, creditTekst, wanneer) {
-  const artiest = {
-    type: 'Artist',
-    id: actorId(base, site.slug),
-    name: site.title || site.slug,
-    published: site.created_at ? new Date(site.created_at).toISOString() : wanneer,
-  };
-  if (isMbid(site.mb_artist_id)) artiest.musicbrainzId = String(site.mb_artist_id).trim().toLowerCase();
-  return [{
-    type: 'ArtistCredit',
-    id: `${actorId(base, site.slug)}#artist-credit`,
-    published: artiest.published,
-    artist: artiest,
-    ...(creditTekst ? { credit: creditTekst } : {}),
-  }];
-}
-
-/**
- * De identiteit van een track op de draad.
- *
- * Staat apart omdat hij op TWEE momenten nodig is die ver uit elkaar liggen:
- * bij het bouwen van het Audio-object, en bij het verwijderen ervan, wanneer de
- * rij al weg is en er dus niets meer te bouwen valt. Toen dit nog inline stond,
- * kende alleen de bouwkant de vorm en ging er bij verwijderen geen Delete uit --
- * elke server die de track had geindexeerd hield hem voor altijd (21-8).
- */
-export function trackUri(base, site, id) {
-  return `${actorId(base, site.slug)}/tracks/${encodeURIComponent(id)}`;
-}
-
-export function buildTrackAudio(base, site, r, opts = {}) {
-  const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
-  const fn = r.filename || (r.storage_path || '').split('/').pop();
-  // De bestandsgegevens horen bij de LINK, niet bij het object: het is die ene
-  // representatie die zoveel bytes is en die bitrate heeft, niet het nummer.
-  // Zo doet Funkwhale het ook.
-  // De post waar dit nummer in staat. Meegegeven door de collectie (een
-  // zoekopdracht voor alles), of hier opgezocht als deze track los wordt
-  // opgehaald. `hostPosts` mag expliciet null zijn: dan is er niets te zoeken.
-  const post = opts.hostPosts !== undefined
-    ? (opts.hostPosts && opts.hostPosts.get(r.id)) || null
-    : ((site.id && trackHostPosts(site.id).get(r.id)) || null);
-
-  const bestand = { type: 'Link', href: `${base}/audio/stream/${encodeURIComponent(fn)}`, mediaType: r.mime_type || 'audio/mpeg' };
-  if (Number(r.size)) bestand.size = Number(r.size);
-  // Bitrate leiden we af uit bytes en seconden. Geen gok: voor een bestand IS
-  // dat de gemiddelde bitrate, en bij CBR ook de echte. Alleen als we allebei
-  // de getallen hebben -- liever geen veld dan een verzonnen getal.
-  if (Number(r.size) && Number(r.duration)) bestand.bitrate = Math.round((Number(r.size) * 8) / Number(r.duration));
-
-  const a = {
-    ...(opts.standalone ? { '@context': AP_CONTEXT } : {}),
-    id: trackUri(base, site, r.id),
-    type: 'Audio',
-    name: r.title || 'Audio',
-    attributedTo: actorId(base, site.slug),
-    // Op het OBJECT, niet alleen op de omhullende Create: een los opgehaalde
-    // track moet zelf kunnen zeggen dat hij openbaar is.
-    to: [PUBLIC],
-    // De post die dit nummer uitbrengt staat VOORAAN als text/html, precies
-    // zoals Funkwhale zijn trackpagina zet. Wij hadden dat veld leeg gelaten
-    // omdat Klonkt geen trackpagina heeft -- maar de post IS waar je het kunt
-    // horen, en dat is wat zo'n link betekent.
-    url: [...(post ? [{ type: 'Link', href: `${base}/${post.slug}`, mediaType: 'text/html' }] : []), bestand],
-  };
-  // De bak waar dit bestand in hangt (shaer-0nh). Voor Funkwhale is dit het
-  // haakje waaraan een upload komt te zitten; zonder dit veld blijft een track
-  // daar een naam zonder geluid.
-  a.library = libraryId(base, site);
-  if (r.artist) a.summary = r.artist;              // artiest als summary: kaal AS2, geen eigen vocab
-  // AS2-kern `context`: "de context waarbinnen dit object bestaat". Voor een
-  // track is dat de post die hem uitbrengt. Daarmee is de relatie die tot nu
-  // toe alleen in posts.content stond, op de draad te zien -- en kan een lezer
-  // die de post al heeft dit nummer overslaan in plaats van er een lege kaart
-  // van te maken.
-  if (post) a.context = noteId(base, post.id);
-  // Het NUMMER, los van dit bestand (shaer-3f8a, spoor B). Funkwhale en
-  // Emissary lezen allebei `fw:track`, en petitminion noemde het ontbreken
-  // ervan als eerste wat hem opviel aan onze objecten.
-  //
-  // EIGEN ID MET #track, en niet hetzelfde id als de Audio. Emissary hergebruikt
-  // daar het object-id, maar dan zijn in JSON-LD de Audio en de Track EEN knoop
-  // met twee typen -- en een bestand is geen werk. Dat verschil moeten we straks
-  // toch maken, want een album verzamelt nummers en geen mp3's. Een fragment is
-  // een geldige IRI en wijst naar hetzelfde document.
-  //
-  // GEEN `album`. Dat veld is bij hen een URI naar een Album-object en bij ons
-  // een tekstkolom; er hier een adres van maken zou een ding beloven dat niet
-  // bestaat. Zie shaer-k37k -- dat is de keuze die daarvoor eerst moet vallen.
-  //
-  // WIE IS DE ARTIEST. Hun Artist is een ENTITEIT met een id, en bij ons is een
-  // artiest een tekstkolom op de track. Die twee verzoenen we zo: de entiteit
-  // is de site-ACTOR -- een echt, opvraagbaar adres, het account dat dit
-  // uitbrengt -- en de tekst uit de kolom gaat naar `credit`, want dat is
-  // precies waar hun model de credittekst verwacht.
-  //
-  // Dat is eerlijk en het is niet nieuw: open.audio leidde op 13-8 al zelf een
-  // artist_credit af uit onze attributedTo. We maken alleen expliciet wat daar
-  // toch al gebeurde.
-  //
-  // DE GRENS ERVAN: brengt een site werk van iemand anders uit, dan zegt dit
-  // dat de site de artiest is. Dat stond al in attributedTo, dus we maken het
-  // niet erger -- maar het is wel de reden dat we hier geen id per artiestnaam
-  // verzinnen. Identiteit uit een string is dezelfde fout als bij het album
-  // (shaer-756s).
-  const wanneer = r.created_at ? new Date(r.created_at).toISOString()
-    : (site.created_at ? new Date(site.created_at).toISOString() : new Date(0).toISOString());
-
-  a.track = {
-    type: 'Track',
-    id: `${a.id}#track`,
-    name: a.name,
-    published: wanneer,
-    ...(Number(r.position) ? { position: Number(r.position) } : {}),
-    artist_credit: artistCredit(base, site, r.artist, wanneer),
-  };
-  // De uitgave waar dit nummer op staat, INGESLOTEN (shaer-756s, stap 2).
-  // `albums` mag expliciet null zijn: dan is er niets op te zoeken.
-  const uitgave = opts.albums !== undefined
-    ? (opts.albums && opts.albums.get(r.id)) || null
-    : ((site.id && trackAlbums(site.id).get(r.id)) || null);
-  if (uitgave) {
-    a.track.album = buildAlbumObject(base, site, uitgave);
-    // Ook op het Audio-object zelf, als URI. Funkwhale 2.0 en Emissary doen dat
-    // allebei, en het scheelt een lezer het uitpakken van de track.
-    a.album = a.track.album.id;
-  }
-  if (r.duration) a.duration = `PT${Math.round(r.duration)}S`;
-  if (r.created_at) a.published = new Date(r.created_at).toISOString();
-  if (Number(r.position)) a.position = Number(r.position);
-  const lic = licentieUri(r.license);
-  if (lic) a.license = lic;
-  const art = abs(r.cover_url || opts.coverFallback || null);
-  // icon EN image: allebei AS2-kern. Wij gebruikten alleen icon; Funkwhale
-  // leest image. Dezelfde hoes, twee namen, niemand die iets misloopt.
-  if (art) {
-    const plaat = { type: 'Image', mediaType: guessMediaType(art), url: art };
-    a.icon = plaat;
-    a.image = plaat;
-  }
-  return a;
-}
-
-/**
- * Onze licentie is VRIJE TEKST uit een keuzelijst ("CC BY 4.0", "Alle rechten
- * voorbehouden"); schema.org en Funkwhale willen een URI. Alleen de waarden die
- * onze eigen keuzelijst aanbiedt worden vertaald -- die kennen we exact. Al het
- * andere levert niets op: een zelfbedachte licentie-URI is erger dan geen, want
- * een lezer gelooft hem.
- */
-const LICENTIES = {
-  'cc0 1.0 (publiek domein)': 'http://creativecommons.org/publicdomain/zero/1.0/',
-  'cc by 4.0': 'http://creativecommons.org/licenses/by/4.0/',
-  'cc by-sa 4.0': 'http://creativecommons.org/licenses/by-sa/4.0/',
-  'cc by-nc 4.0': 'http://creativecommons.org/licenses/by-nc/4.0/',
-  'cc by-nc-sa 4.0': 'http://creativecommons.org/licenses/by-nc-sa/4.0/',
-  'cc by-nd 4.0': 'http://creativecommons.org/licenses/by-nd/4.0/',
-};
-export function licentieUri(waarde) {
-  const s = String(waarde || '').trim();
-  if (!s) return null;
-  if (/^https?:\/\//i.test(s)) return safeUrl(s);   // iemand vulde al een URI in
-  return LICENTIES[s.toLowerCase()] || null;        // "Alle rechten voorbehouden" heeft er geen
-}
-
-/** Het AS2-id van de bibliotheek van een site. */
-export function libraryId(base, site) {
-  return `${actorId(base, site.slug)}/library`;
-}
-
-/**
- * De site als Funkwhale-LIBRARY (skelet).
- *
- * WAAROM DIT GEEN DIALECT IS ZOALS track EN ArtistCredit DAT WEL ZIJN. Die twee
- * vragen entiteiten waar wij tekst hebben; hiervoor hoeven we niets te
- * verzinnen. Een library is precies wat er al staat: onze open tracks, met een
- * echte telling en een echt id.
- *
- * WAAROM HET NODIG IS, gemeten op 13-8. open.audio heeft onze vier tracks
- * binnengehaald langs de AP-weg -- met ONZE track-id's, en met een artist_credit
- * dat Funkwhale zelf uit onze attributedTo afleidde. Maar `uploads` is leeg en
- * `is_playable` false. Bij hen hangt een upload aan een library; zonder library
- * is er geen bak om het bestand in te hangen. Het audiobestand zelf is wel
- * gewoon op te halen (200, audio/mpeg, ook anoniem) -- ze hebben het niet
- * geprobeerd.
- *
- * SKELET, en dat woord is letterlijk bedoeld. Dit is de vorm uit hun docs:
- * type, id, name, followers, totalItems, first, last, plus attributedTo en
- * summary. Wat er NIET is: de volg-afhandeling. Onze bibliotheek is openbaar --
- * elke track erin heeft fedi_open -- dus er valt niets goed te keuren. Komt er
- * ooit een besloten variant, dan hoort daar het Follow/Accept-werk bij.
- */
-export function buildLibrary(base, site, rows, { page = false } = {}) {
-  const id = libraryId(base, site);
-  const hostPosts = site.id ? trackHostPosts(site.id) : null;
-  const albums = site.id ? trackAlbums(site.id) : null;
-  const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { hostPosts, albums }));
-  return pagedCollection(id, items, {
-    page,
-    // Een platenkast is geen tijdlijn: `Collection`, niet `OrderedCollection`.
-    // Funkwhale's LibrarySerializer accepteert ook alleen die twee typen
-    // (as:Collection of fw:Library) en zijn CollectionPageSerializer alleen
-    // `CollectionPage` met `items`.
-    ongeordend: true,
-    extra: {
-      type: 'Library',
-      name: site.title || site.slug,
-      attributedTo: actorId(base, site.slug),
-      // WAAROM DIT VELD ER MOET STAAN. Funkwhale's LibrarySerializer noemt
-      // `audience` optioneel, maar zijn create() doet er meteen
-      // `privacy[validated_data["audience"]]` mee -- zonder de sleutel is dat
-      // een KeyError en geeft hun server een 500. Dat is wat open.audio op 15-8
-      // teruggaf toen Robin onze library-URI daar opzocht.
-      //
-      // Het is bovendien gewoon waar: alles hierin is fedi_open, dus openbaar.
-      // Bij hen is dit precies het verschil tussen privacy_level 'everyone' en
-      // 'me' -- oftewel of onze nummers daar afspeelbaar zijn.
-      audience: 'https://www.w3.org/ns/activitystreams#Public',
-      // Vereist volgens hun docs. Openbaar, dus de telling is eerlijk en de
-      // lijst blijft leeg -- wie ons volgt volgt de ACTOR, niet de bak.
-      followers: `${id}/followers`,
-      ...(site.description ? { summary: String(site.description).slice(0, 500) } : {}),
-    },
-  });
-}
-
-/** De collectie van alle open tracks van een site (shaer-0nh, stap 3). */
-export function buildTrackCollection(base, site, rows, { page = false } = {}) {
-  // Eén zoekopdracht voor alle rijen samen; zie trackHostPosts.
-  const posts = site.id ? trackHostPosts(site.id) : null;
-  const albums = site.id ? trackAlbums(site.id) : null;
-  const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { hostPosts: posts, albums }));
-  return pagedCollection(`${actorId(base, site.slug)}/tracks`, items, { page, extra: { attributedTo: actorId(base, site.slug) } });
-}
-
-// Een post die een playlist insluit wijst in zijn AS2 ook naar de collectie
-// (shaer-ayc, stap 2): een Link-tag per ingesloten playlist. Mastodon
-// parseert alleen Mention/Hashtag/Emoji en negeert een Link geruisloos; een
-// client die hem kent haalt de collectie op. Opgelost uit post.content en
-// ALLEEN binnen de eigen site: playlist-ids zijn een globale primary key, dus
-// zonder site-check zou een post van site A naar de collectie van site B
-// kunnen wijzen.
-export function playlistLinkTags(base, site, content, post = null) {
-  const out = [];
-  try {
-    // Zelfde patroon als de renderer en als de afleiding: wat niet insluit,
-    // krijgt ook geen link. Dit stond hier met een eigen patroon dat
-    // underscores accepteerde die nergens anders meetellen.
-    for (const id of ingeslotenPlaylists(content)) {
-      const pl = db.prepare('SELECT id, title FROM playlists WHERE id = ? AND site_id = ?').get(id, site.id);
-      if (!pl) continue;
-      out.push({ type: 'Link', href: `${actorId(base, site.slug)}/playlists/${pl.id}`, mediaType: 'application/activity+json', name: pl.title });
-    }
-    // Losse tracks in een post zijn ook een uitgave (shaer-38y): ze krijgen een
-    // eigen collectie, en de post wijst er langs dezelfde weg naar. Zonder deze
-    // link zou die collectie bestaan maar door niemand te vinden zijn.
-    if (post && post.id) {
-      const eenheid = postMusicType(content, site.id);
-      if (eenheid && !eenheid.collectie && eenheid.tracks?.length && losseTracksVanPost(site.id, eenheid.tracks).length) {
-        out.push({
-          type: 'Link',
-          href: postTracksId(base, site, post.id),
-          mediaType: 'application/activity+json',
-          name: post.title || 'Tracks',
-        });
-      }
-    }
-  } catch { /* niet-fataal: een tag minder, geen kapotte Note */ }
-  return out;
-}
-
-// De lijst van alle playlist-collecties van een site (shaer-ayc, stap 2).
-// Kaal standaard (URI's), verrijkt op verzoek (FEP-9876, zelfde conventie als
-// followers/following): een stub per playlist met naam, hoes en de EERLIJKE
-// telling -- totalItems van de stub telt het open deel, dezelfde regel als de
-// collectie zelf, want ook een lijst mag niet verklappen wat er achter de
-// poort staat.
-export function listPlaylistsAP(base, site, enriched, { page = false } = {}) {
-  const rows = db.prepare(
-    'SELECT id, title, artist, year, cover_url FROM playlists WHERE site_id = ? ORDER BY created_at, id'
-  ).all(site.id);
-  const colId = `${actorId(base, site.slug)}/playlists`;
-  const items = rows.map((p) => {
-    const uri = `${actorId(base, site.slug)}/playlists/${p.id}`;
-    if (!enriched) return uri;
-    const stub = buildPlaylistCollection(base, site, p, playlistOpenTracks(p.id));
-    delete stub['@context'];       // genest object draagt de context van zijn omhulsel
-    delete stub.orderedItems;      // stub: wie de tracks wil, haalt de collectie op
-    return stub;
-  });
-  return pagedCollection(colId, items, { page, extra: { attributedTo: actorId(base, site.slug) } });
-}
-
-/**
- * Bij welke UITGAVE hoort een track? (shaer-756s, stap 2)
- *
- * Alleen playlists met kind='album' tellen: een mixtape is geen uitgave, en dat
- * onderscheid is precies wat de keuze album/playlist betekent. Zit een track in
- * twee albums, dan wint de oudste -- willekeurig maar STABIEL, en dat is wat
- * telt: een id dat per ophaalactie verspringt is erger dan een id dat niet de
- * mooiste keuze is.
- *
- * Eén zoekopdracht voor alle rijen samen, zoals trackHostPosts. Per track
- * vragen wordt bij tweehonderd nummers tweehonderd zoekopdrachten.
- */
-export function trackAlbums(siteId) {
-  const rijen = db.prepare(`
-    SELECT pt.track_id AS tid, p.id, p.title, p.artist, p.year, p.cover_url,
-           p.release_date, p.mb_release_id, p.created_at
-      FROM playlist_tracks pt
-      JOIN playlists p ON p.id = pt.playlist_id
-     WHERE p.site_id = ? AND p.kind = 'album'
-     ORDER BY p.created_at, p.id
-  `).all(siteId);
-  const uit = new Map();
-  for (const r of rijen) if (!uit.has(r.tid)) uit.set(r.tid, r);
-  // De post die deze plaat uitbrengt, EEN keer per album opgezocht en niet per
-  // track: uitgavePost() doet er echt werk voor (hij leest de typering van de
-  // post) en een site heeft veel meer nummers dan platen.
-  //
-  // WAAROM DIT ERBIJ MOET: buildPlaylistCollection laat leenVanPost de naam van
-  // de post overnemen -- de post IS de uitgave. Zonder dezelfde lening hier zou
-  // het ingesloten Album "Cartoon Epic" heten en zijn eigen URI "Geen koffie,
-  // wel thee!". Een id met twee namen, en dat is precies wat op 16-8 uit de
-  // meting rolde.
-  const perAlbum = new Map();
-  for (const r of uit.values()) {
-    if (perAlbum.has(r.id)) continue;
-    perAlbum.set(r.id, uitgavePost(siteId, r.id));
-  }
-  for (const r of uit.values()) r._post = perAlbum.get(r.id) || null;
-  return uit;
-}
-
-/**
- * Een uitgave als `fw:Album`.
- *
- * INGESLOTEN EN NIET ALS URI, en dat is het hele punt van deze stap. Funkwhale's
- * TrackSerializer heeft `album = AlbumSerializer()` -- een object met name,
- * published en een eigen artist_credit. Een kale URI expandeert naar een knoop
- * met alleen een @id en valt daar dus af. Emissary stuurt precies zo'n kale URI,
- * en dat is waarom hun tracks bij Funkwhale net zo goed stranden.
- *
- * Het `id` is de bestaande playlist-collectie: dereferenceerbaar, en het is
- * werkelijk hetzelfde ding. We verzinnen geen tweede adres voor iets dat er al
- * een heeft.
- */
-/**
- * Het bandje: EEN object, samengesteld uit de nummers van een playlist.
- *
- * Waarom het een eigen soort is en geen album met een ander jasje. Een album is
- * een uitgave: het heeft een uitgavedatum, een release-id, en de nummers
- * bestaan er los van. Een mixtape is het omgekeerde -- de volgorde IS het werk,
- * en de nummers zijn er onderdelen van. Vandaar `orderedItems` op het object
- * zelf in plaats van een collectie ernaast, en vandaar geen `released` en geen
- * `musicbrainzId`: die zouden beweren dat dit een uitgave is.
- *
- * GEEN EIGEN `url`, en dat is een keuze van Robin (21-8) met een prijs die het
- * waard is om hier op te schrijven. Het bandje is een logische omhulling, geen
- * gerenderd bestand: er wordt niets samengevoegd. Een ontvanger die `Mixtape`
- * niet kent heeft dus geen stream om te spelen. Dat is bewust -- de nummers
- * staan er stuk voor stuk in, met hun eigen id en hun eigen url, dus er gaat
- * niets verloren; het kost alleen een consument die het type wel begrijpt.
- *
- * `type` is een STRING en geen array. Dat is geen slordigheid maar een geleerde
- * les: er stond bij de playlist-collectie ooit ['OrderedCollection', 'Album'],
- * geldig AS2 en werkelijk allebei, en een lezer die `type` als tekst uitpakt
- * (Shaer doet dat) verloor daarmee stil het hele object.
- */
-export function buildMixtapeObject(base, site, pl, rows) {
-  if (!pl) return null;
-  const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
-  const postDatum = (pl._post && pl._post.uit_wanneer) ? new Date(pl._post.uit_wanneer) : null;
-  const wanneer = postDatum ? postDatum.toISOString()
-    : (pl.created_at ? new Date(pl.created_at).toISOString() : null);
-  // Dezelfde lening als bij het album: de post die het bandje uitbrengt geeft
-  // zijn titel, en de eigen titel blijft als alsoKnownAs staan.
-  const titel = (pl._post && pl._post.title) || pl.title;
-  const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { coverFallback: pl.cover_url || null }));
-  const tape = {
-    type: 'Mixtape',
-    id: `${actorId(base, site.slug)}/playlists/${pl.id}`,
-    name: titel,
-    ...(wanneer ? { published: wanneer } : {}),
-    attributedTo: actorId(base, site.slug),
-    artist_credit: artistCredit(base, site, pl.artist, wanneer || new Date().toISOString()),
-    // De kant die het bandje maakt: eerst dit nummer, dan dat. Vooruit en
-    // achteruit is de speler; de volgorde is het object.
-    totalItems: items.length,
-    orderedItems: items,
-  };
-  if (titel !== pl.title) tape.alsoKnownAs = pl.title;
-  // De speelduur van het geheel, want dat is wat een bandje heeft: een lengte.
-  // Alleen als we van ELK nummer de duur kennen -- een som met gaten erin is
-  // een verzonnen getal, en die zetten we hier niet neer (zelfde regel als bij
-  // de bitrate van een track).
-  const duren = (rows || []).map((r) => Number(r.duration) || 0);
-  if (duren.length && duren.every((d) => d > 0)) {
-    tape.duration = `PT${Math.round(duren.reduce((a, b) => a + b, 0))}S`;
-  }
-  const hoes = abs(pl.cover_url || null);
-  if (hoes) tape.image = { type: 'Image', mediaType: guessMediaType(hoes), url: hoes };
-  return tape;
-}
-
-export function buildAlbumObject(base, site, pl) {
-  if (!pl) return null;
-  const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
-  // WANNEER IS DEZE PLAAT GEPUBLICEERD. De post die hem uitbrengt gaat voor, en
-  // niet als noodgreep maar omdat hij het beter weet: playlists.created_at is
-  // het moment waarop de RIJ is aangemaakt, en dat kan weken eerder zijn terwijl
-  // je nog aan het samenstellen was. AS2 `published` vraagt wanneer het object
-  // openbaar werd, en dat is de post.
-  //
-  // GEEN epoch als laatste terugval. `published` is bij hen verplicht, maar 1970
-  // is een ANTWOORD en geen ontbrekend veld -- en dat is erger: een lezer kan een
-  // gat opmerken, een leugen niet. Zo kwam op 16-8 de route boven water die id,
-  // title, artist, year, cover_url en kind selecteerde en de rest niet.
-  //
-  // OOK VOOR `released`, en daar had ik het eerst mis (Robin, 16-8). Mijn
-  // bezwaar was: post je vandaag een plaat uit 2018, dan beweert dit dat hij
-  // vandaag uitkwam. Dat gebeurt ook -- maar bij de meeste Klonkt-sites IS de
-  // post het uitbrengen, en GEEN datum is slechter dan een datum die op het
-  // gewone geval klopt. Het handmatige veld is precies het gereedschap voor de
-  // uitzondering: bij een heruitgave vul je hem in en die wint.
-  const postDatum = (pl._post && pl._post.uit_wanneer) ? new Date(pl._post.uit_wanneer) : null;
-  const wanneer = postDatum ? postDatum.toISOString()
-    : (pl.created_at ? new Date(pl.created_at).toISOString() : null);
-  // Dezelfde lening als in buildPlaylistCollection: de post die de plaat
-  // uitbrengt geeft zijn titel, en de eigen titel blijft als alsoKnownAs staan.
-  const titel = (pl._post && pl._post.title) || pl.title;
-  const album = {
-    type: 'Album',
-    id: `${actorId(base, site.slug)}/playlists/${pl.id}`,
-    name: titel,
-    ...(wanneer ? { published: wanneer } : {}),
-    attributedTo: actorId(base, site.slug),
-    artist_credit: artistCredit(base, site, pl.artist, wanneer || new Date().toISOString()),
-  };
-  if (titel !== pl.title) album.alsoKnownAs = pl.title;
-  // Het ingevulde veld wint altijd; anders de DAG waarop de post verscheen.
-  // `year` vult hem nog steeds niet aan, en dat is geen inconsequentie: een
-  // jaartal is geen dag, terwijl de postdatum een gebeurtenis is die werkelijk
-  // heeft plaatsgevonden. Het verschil is verzinnen versus afleiden.
-  if (pl.release_date) album.released = pl.release_date;
-  else if (postDatum) album.released = postDatum.toISOString().slice(0, 10);
-  if (pl.mb_release_id) album.musicbrainzId = pl.mb_release_id;
-  const hoes = abs(pl.cover_url || null);
-  if (hoes) album.image = { type: 'Image', mediaType: guessMediaType(hoes), url: hoes };
-  return album;
-}
-
-export function buildPlaylistCollection(base, site, playlist, rows) {
-  const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
-  // Dezelfde objecten als in de actor-collectie, met hetzelfde id (shaer-0nh,
-  // stap 3). Een playlist is een KEUZE uit wat de artiest heeft uitgebracht,
-  // geen tweede exemplaar ervan: staat een track in twee playlists, dan is het
-  // twee keer hetzelfde ding en niet twee dingen die toevallig gelijk klinken.
-  // De hoes van de playlist dient als terugval voor een track zonder eigen hoes.
-  const hostPosts = site.id ? trackHostPosts(site.id) : null;
-  const albums = site.id ? trackAlbums(site.id) : null;
-  // EEN keer opzoeken en twee keer gebruiken: het Album leent er zijn datums en
-  // titel van, leenVanPost onderaan zijn tekst en tags. Twee losse aanroepen
-  // zouden niet alleen dubbel werk zijn maar ook uiteen kunnen lopen -- en dan
-  // staat er weer iets anders op het ingesloten object dan op zijn eigen URI.
-  const post = site.id ? uitgavePost(site.id, playlist.id) : null;
-  const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { coverFallback: playlist.cover_url || null, hostPosts, albums }));
-  const out = pagedCollection(`${actorId(base, site.slug)}/playlists/${playlist.id}`, items, {
-    extra: { name: playlist.title, attributedTo: actorId(base, site.slug) },
-  });
-  // Album of playlist is presentatie; op de draad is het één samenvattingsveld.
-  const parts = [];
-  if (playlist.artist) parts.push(playlist.artist);
-  if (playlist.year) parts.push(String(playlist.year));
-  if (parts.length) out.summary = parts.join(' · ');
-  const cover = abs(playlist.cover_url || null);
-  if (cover) out.icon = { type: 'Image', mediaType: guessMediaType(cover), url: cover };
-
-  // Is dit een UITGAVE, dan draagt deze collectie ook de albumvelden
-  // (shaer-756s, stap 2): het is het adres waar track.album naar wijst, en dan
-  // hoort hier hetzelfde te staan als in het ingesloten object.
-  //
-  // `type` blijft OrderedCollection, EN BLIJFT EEN STRING. Er stond hier even
-  // ['OrderedCollection', 'Album'] -- geldig AS2, en het is ook werkelijk
-  // allebei -- maar een bestaande test viel erover, en die test had gelijk: een
-  // lezer die `type` als tekst uitpakt (Shaer doet dat) verliest dan in stilte
-  // de hele playlist. Het kost ons niets, want hun AlbumSerializer declareert
-  // geen type-veld en valideert het dus niet: haalt Funkwhale dit adres op als
-  // album, dan leest hij deze velden gewoon. En het object dat hij echt gebruikt
-  // staat toch al ingesloten op de track.
-  const soort = SOORTEN.includes(playlist.kind) ? playlist.kind : 'album';
-  if (soort === 'album') {
-    const album = buildAlbumObject(base, site, { ...playlist, _post: post });
-    for (const veld of ['published', 'released', 'musicbrainzId', 'artist_credit', 'image']) {
-      if (album[veld] !== undefined) out[veld] = album[veld];
-    }
-  }
-  // Een mixtape draagt hier zijn eigen velden, om dezelfde reden als het album:
-  // dit adres is waar een lezer terechtkomt die het bandje wil ophalen, en dan
-  // hoort er hetzelfde te staan als in het ingesloten object. `type` blijft ook
-  // hier OrderedCollection -- zie de uitleg hierboven over Shaer.
-  if (soort === 'mixtape') {
-    const tape = buildMixtapeObject(base, site, { ...playlist, _post: post }, rows);
-    for (const veld of ['published', 'artist_credit', 'image', 'duration', 'alsoKnownAs']) {
-      if (tape[veld] !== undefined) out[veld] = tape[veld];
-    }
-  }
-  return leenVanPost(base, site, out, post);
-}
-
-// ── De post als uitgave (shaer-38y) ───────────────────────────────────────
-
-/** Het AS2-id van de collectie losse tracks van een post. */
-function postTracksId(base, site, postId) {
-  return `${actorId(base, site.slug)}/posts/${encodeURIComponent(postId)}/tracks`;
-}
-
-/**
- * Welke post brengt deze playlist uit, en mag die zijn gegevens uitlenen?
- *
- * Niet zomaar de eerste post die de playlist noemt: alleen een post die er EEN
- * muzikale eenheid van maakt leent uit. Staan er twee collecties in, dan is de
- * post niet meer de drager van een identiteit en houdt de playlist de zijne --
- * dezelfde regel als in de afleiding, hier alleen toegepast.
- *
- * De nieuwste wint als er meerdere zijn: dat is waar hij het laatst is
- * uitgebracht.
- */
-export function uitgavePost(siteId, playlistId) {
-  if (!siteId || !playlistId) return null;
-  try {
-    const rijen = db.prepare(`
-      SELECT id, slug, title, excerpt, content, cover_image_url, tags,
-             ${isoSql('COALESCE(published_at, created_at)')} AS uit_wanneer
-      FROM posts
-      WHERE site_id = ? AND status = 'published'
-        AND content LIKE '%[[playlist:' || ? || ']]%'
-      ORDER BY ${isoSql('COALESCE(published_at, created_at)')} DESC
-    `).all(siteId, playlistId);
-    for (const p of rijen) {
-      const r = postMusicType(p.content, siteId);
-      if (r && r.leentMetadata && r.collectie && r.collectie.id === playlistId) return p;
-    }
-  } catch { /* geen lening is geen fout */ }
-  return null;
-}
-
-/**
- * De post leent zijn gegevens aan de uitgave (shaer-38y, punt 3).
- *
- * WAAROM DE POST WINT EN NIET DE PLAYLIST. Een playlist heeft een titel en soms
- * een hoes; een post heeft een titel, een tekst, een hoes, tags EN een datum.
- * Voor audio-gebaseerde inhoud is de post de uitgave -- dat is waar iemand hem
- * heeft uitgebracht en waar het verhaal erbij staat. Een Funkwhale-achtige
- * lezer vindt een collectie met alleen een naam te mager, en dat is precies wat
- * hij nu krijgt.
- *
- * De naam van de playlist gaat niet verloren: die blijft als `alsoKnownAs`
- * staan, zodat de eigen naam terug te vinden is als hij afwijkt.
- */
-function leenVanPost(base, site, obj, post) {
-  if (!post) return obj;
-  const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
-
-  if (post.title) {
-    if (obj.name && obj.name !== post.title) obj.alsoKnownAs = obj.name;
-    obj.name = post.title;
-  }
-  // De tekst als `content`, niet als `summary`: in AS2 is summary de korte
-  // samenvatting en content het lijf. Artiest en jaar blijven dus in summary
-  // staan -- dat is een samenvatting, en de posttekst is dat niet.
-  const tekst = tekstVanPost(post);
-  if (tekst) obj.content = tekst;
-
-  const cover = abs(post.cover_image_url || null);
-  if (cover) {
-    obj.image = { type: 'Image', mediaType: guessMediaType(cover), url: cover };
-    if (!obj.icon) obj.icon = obj.image;      // geen eigen hoes? dan die van de post
-  }
-
-  // Dezelfde lijst als de Note: het tagveld EN de hashtags uit het lijf, waarbij
-  // de geschreven vorm voorgaat. Een eigen lijst hier zou de tags uit de tekst
-  // missen en de rest anders spellen dan dezelfde post elders doet.
-  const tags = buildHashtagList(base, post.tags, post.content, { ruw: true });
-  if (tags.length) obj.tag = tags;
-
-  // Waar je hem kunt horen, en waar hij bij hoort. Zelfde paar als bij een
-  // losse track: url wijst een mens naar de post, context zegt waar dit object
-  // thuishoort.
-  obj.url = `${base}/${post.slug}`;
-  obj.context = noteId(base, post.id);
-  return obj;
-}
-
-/**
- * De tekst van een post, als er een is. De excerpt heeft voorrang -- die is
- * geschreven om samen te vatten. Staat die leeg, dan het lijf zelf: zonder
- * shortcodes (die zijn de muziek, niet het verhaal erover) en zonder opmaak.
- * Levert null als er niets overblijft, want een leeg veld is slechter dan geen.
- */
-function tekstVanPost(post) {
-  const excerpt = String(post.excerpt || '').trim();
-  if (excerpt) return excerpt;
-  const kaal = String(post.content || '')
-    .replace(/\[\[[a-z]+:[^\]]*\]\]/gi, ' ')
-    .replace(/<[^>]+>/g, ' ')
-    .replace(/&nbsp;/gi, ' ')
-    .replace(/&[a-z#0-9]+;/gi, ' ')
-    // Losse hashtags gaan eruit: die staan al in `tag`, en een description die
-    // de tagwolk herhaalt is ruis. Live leverde dit "#DoenweNiet #DoenWeNiet
-    // #devs" op als omschrijving van een post die verder geen tekst heeft.
-    .replace(/(^|\s)#[\p{L}\p{M}\p{N}_]+/gu, ' ')
-    .replace(/\s+/g, ' ')
-    .trim();
-  return kaal || null;
-}
-
-/** De open tracks uit een lijst ids, in de volgorde van die lijst. */
-function losseTracksVanPost(siteId, ids) {
-  if (!siteId || !ids?.length) return [];
-  const gaten = ids.map(() => '?').join(',');
-  const rijen = db.prepare(
-    `SELECT ${TRACK_KOLOMMEN}
-     FROM audio_tracks t JOIN media m ON m.id = t.media_id
-     WHERE t.site_id = ? AND t.fedi_open = 1 AND t.id IN (${gaten})`
-  ).all(siteId, ...ids);
-  // De volgorde van de POST, niet die van de tabel (shaer-38y, punt 1): zoals
-  // iemand ze heeft neergezet is de volgorde waarin ze bedoeld zijn.
-  const opId = new Map(rijen.map((r) => [r.id, r]));
-  return ids.map((id) => opId.get(id)).filter(Boolean);
-}
-
-/**
- * De losse tracks van een post als EEN uitgave (shaer-38y).
- *
- * Tot nu toe gingen die los de deur uit: losse Audio-objecten die een lezer
- * nergens kon plaatsen. Ze horen bij elkaar omdat ze in dezelfde post staan, en
- * dat is wat deze collectie zegt -- met de gegevens van de post erbij, want die
- * heeft ze wel en de losse tracks niet.
- *
- * Geeft null als er niets te tonen is: geen post, geen losse tracks, of een
- * post die geen enkele muzikale eenheid IS.
- */
-export function buildPostTrackCollection(base, site, post) {
-  if (!post || !post.id) return null;
-  const eenheid = postMusicType(post.content, site.id);
-  if (!eenheid || eenheid.collectie || !eenheid.tracks?.length) return null;
-
-  const rows = losseTracksVanPost(site.id, eenheid.tracks);
-  if (!rows.length) return null;
-
-  const hostPosts = new Map(rows.map((r) => [r.id, { id: post.id, slug: post.slug }]));
-  const out = pagedCollection(postTracksId(base, site, post.id),
-    rows.map((r) => buildTrackAudio(base, site, r, { hostPosts })),
-    { extra: { attributedTo: actorId(base, site.slug) } });
-  return leenVanPost(base, site, out, post);
-}
-
-/**
- * Een track als publicatie: Create(Audio) (shaer-0nh, stap 4).
- *
- * Zelfde vorm als buildCreate voor een post, met een STABIEL id: dezelfde track
- * levert altijd dezelfde activiteit, zodat een lezer die de outbox twee keer
- * ophaalt niet denkt dat er iets nieuws is.
- */
-export function buildTrackCreate(base, site, r, opts = {}) {
-  const audio = buildTrackAudio(base, site, r, opts);
-  const me = actorId(base, site.slug);
-  return {
-    '@context': AP_CONTEXT,
-    id: `${audio.id}#create`,
-    type: 'Create',
-    actor: me,
-    published: audio.published,
-    to: [PUBLIC],
-    cc: [`${me}/followers`],
-    object: audio,
-  };
-}
-
-/**
- * `category` is kanaal-vocabulaire, en de waarde is 'music' (Robins keuze, 7-8).
- * Alleen gezet als de site ECHT audio publiceert: een blog zonder muziek als
- * muziekkanaal aankondigen is erger dan geen label. Het signaal is een track in
- * de kast, niet enable_audio_player -- die staat standaard aan en zegt niets.
- */
-export function channelCategory(site) {
-  try {
-    // ALLEEN opengezette tracks tellen. Eerst keek dit naar elke track, ook een
-    // gated -- en dan roept een site met uitsluitend afgeschermde muziek toch
-    // "hier is muziek" naar de hele fediverse. Dat botst met de regel die we
-    // overal aanhouden: een gesloten track is AFWEZIG, niet stilletjes
-    // aanwezig. Naar buiten toe is een kanaal zonder publieke muziek geen
-    // muziekkanaal.
-    return db.prepare('SELECT 1 FROM audio_tracks WHERE site_id = ? AND fedi_open = 1 LIMIT 1').get(site.id) ? 'music' : null;
-  } catch { return null; }
-}
-
-// ── Welk soort muzikale uitgave is deze post? (shaer-cyg) ─────────────
-
-/**
- * Het type van een post afleiden uit de muziek die erin staat.
- *
- * DE REGEL ZELF staat in assets/js/shared/post-music-type.js, want de editor
- * gebruikt hem ook -- daar volgt het type live mee terwijl je schrijft. Twee
- * kopieen zouden stil uit elkaar lopen, dus is er er een. Hier komt alleen het
- * stuk bij dat de server kan en de browser niet: de gekozen soort van een
- * playlist opzoeken.
- *
- * WAARVOOR DIT WEL EN NIET IS (Robins afbakening, 9-8). Nieuwe posts krijgen
- * hun type uit de keuze: album of playlist wordt gekozen als de playlist wordt
- * gemaakt, en de post neemt dat over. Op de server is dit vooral voor wat er al
- * staat -- de posts met type=audio uit de tijd voor die keuze bestond.
- *
- * @param {string} content   de HTML/tekst van de post
- * @param {string} siteId    nodig om playlists.kind te kunnen opzoeken
- */
-export function postMusicType(content, siteId) {
-  return afleidenUitInsluitingen(content, (id) => playlistKind(id, siteId));
-}
-
-/**
- * De gekozen soort van een playlist: 'album' | 'playlist' | 'mixtape', of null
- * als hij niet (op deze site) bestaat. Zelfde lijst als PlaylistService, via
- * de gedeelde pure module -- alles wat er niet in staat is een album.
- */
-function playlistKind(id, siteId) {
-  if (!siteId) return null;
-  try {
-    const r = db.prepare('SELECT kind FROM playlists WHERE id = ? AND site_id = ?').get(id, siteId);
-    if (!r) return null;
-    return SOORTEN.includes(r.kind) ? r.kind : 'album';
-  } catch { return null; }
-}
Index: src/services/music/luisteraars.js
===================================================================
--- src/services/music/luisteraars.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,73 +1,0 @@
-/**
- * Luisteraars: wie de BIBLIOTHEEK volgt (shaer-0nh).
- *
- * Een aparte soort volger. Ze hangen aan `/ap/users/<slug>/library` en niet aan
- * de actor, en dat verschil is de hele bedoeling: een luisteraar krijgt de
- * muziek en NIET de gewone posts. Iemand die zich abonneert op een
- * platenkast heeft niet gevraagd om de Krant.
- *
- * WAAROM EEN EIGEN TABEL EN GEEN VLAG op ap_followers: zolang ze ergens anders
- * staan kan een bezorging ze niet per ongeluk meenemen. Een vlag die iemand
- * vergeet te filteren doet dat wel, en dan is de fout stil -- de posts komen
- * gewoon aan bij mensen die er niet om vroegen, en niemand ziet het aan onze
- * kant. Dit is dezelfde afweging als bij de wachtrijen: de vorm moet de fout
- * onmogelijk maken, niet alleen onwaarschijnlijk.
- */
-import db from '../../config/database.js';
-
-const stmt = (sql) => db.prepare(sql);
-
-/** Erbij, of bijwerken als hij er al was. Volgen is idempotent. */
-export function voegToe(slug, { actorUri, inbox, sharedInbox, name, handle, icon }) {
-  if (!slug || !actorUri) return false;
-  try {
-    stmt(`INSERT INTO ap_library_followers (slug, actor_uri, inbox, shared_inbox, name, handle, icon)
-          VALUES (?,?,?,?,?,?,?)
-          ON CONFLICT (slug, actor_uri) DO UPDATE SET
-            inbox = excluded.inbox, shared_inbox = excluded.shared_inbox,
-            name = excluded.name, handle = excluded.handle, icon = excluded.icon`)
-      .run(slug, actorUri, inbox || null, sharedInbox || null, name || null, handle || null, icon || null);
-    return true;
-  } catch { return false; }
-}
-
-/** Weg. Een Undo(Follow) hoort meteen te werken, niet pas na een opruiming. */
-export function verwijder(slug, actorUri) {
-  try { return stmt('DELETE FROM ap_library_followers WHERE slug = ? AND actor_uri = ?').run(slug, actorUri).changes > 0; }
-  catch { return false; }
-}
-
-/** Voor het beheerscherm. */
-export function lijst(slug) {
-  try {
-    return stmt(`SELECT actor_uri, inbox, shared_inbox, name, handle, icon, created_at, last_delivery_at, last_error_at
-                 FROM ap_library_followers WHERE slug = ? ORDER BY created_at DESC`).all(slug);
-  } catch { return []; }
-}
-
-export function telling(slug) {
-  try { return stmt('SELECT COUNT(*) n FROM ap_library_followers WHERE slug = ?').get(slug).n; }
-  catch { return 0; }
-}
-
-/** Volgt deze actor onze bibliotheek al? */
-export function isLuisteraar(slug, actorUri) {
-  try { return !!stmt('SELECT 1 FROM ap_library_followers WHERE slug = ? AND actor_uri = ?').get(slug, actorUri); }
-  catch { return false; }
-}
-
-/**
- * De inboxen om muziek naartoe te sturen, ontdubbeld op gedeelde inbox.
- * Nog niemand gebruikt dit -- de bezorging is de volgende stap -- maar het hoort
- * bij de opslag en niet bij de aanroeper.
- */
-export function inboxen(slug) {
-  const uit = new Map();
-  for (const r of lijst(slug)) {
-    const adres = r.shared_inbox || r.inbox;
-    if (adres && !uit.has(adres)) uit.set(adres, r.actor_uri);
-  }
-  return [...uit.keys()];
-}
-
-export default { voegToe, verwijder, lijst, telling, isLuisteraar, inboxen };
