Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision 9da8027b9243dfb7e0896f681a2551b9a2a3d70b)
+++ src/config/database.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
@@ -427,4 +427,20 @@
     );
 
+    -- OpenWebAuth (FEP-61cf): eenmalige tokens waarmee een BEZOEKER van elders
+    -- bewijst wie hij is. Klonkt is hier de 'target instance': we hebben nooit
+    -- iemands prive-sleutel nodig, alleen zijn publieke -- dus staat hier ook
+    -- geen geheim van een ander in.
+    --
+    -- Het token is kort houdbaar (minuten, zie OpenWebAuthService) en gaat na
+    -- inwisselen meteen weg: eenmalig is de hele bedoeling. De FEP noemt de
+    -- opruiming expliciet als DoS-verdediging -- zonder vervaltijd vult iemand
+    -- deze tabel met tokens die hij nooit inwisselt.
+    CREATE TABLE IF NOT EXISTS owa_tokens (
+      token TEXT PRIMARY KEY,
+      actor_uri TEXT NOT NULL,
+      created_at INTEGER NOT NULL          -- ms sinds epoch
+    );
+    CREATE INDEX IF NOT EXISTS idx_owa_tokens_created ON owa_tokens(created_at);
+
     CREATE TABLE IF NOT EXISTS ap_followers (
       id INTEGER PRIMARY KEY AUTOINCREMENT,
Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision 9da8027b9243dfb7e0896f681a2551b9a2a3d70b)
+++ src/routes/activitypub.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
@@ -130,5 +130,39 @@
 }
 
+/**
+ * Is dit een vraag naar de WORTEL van deze server?
+ *
+ * FEP-61cf laat de home instance webfingeren op "the root URL of the destination
+ * site" om ons token-endpoint te vinden. Dat is een URL en geen `acct:`, dus hij
+ * strandde hierboven op de 400 -- terwijl een ACTOR-URI met een pad wel degelijk
+ * een 400 hoort te blijven (dat legt test/webfinger-bare-host.test.js vast, en
+ * dat is een uitgesproken keuze van eerder). Vandaar: alleen origin + '/' telt,
+ * alles met een pad niet.
+ */
+function isEigenWortel(resource, req) {
+  const r = String(resource || '').trim();
+  if (!/^https?:\/\//i.test(r)) return false;
+  try {
+    const u = new URL(r);
+    if (u.pathname && u.pathname !== '/') return false;
+    if (u.search || u.hash) return false;
+    return asciiHost(u.host) === asciiHost(hostOf(req));
+  } catch { return false; }
+}
+
 router.get('/.well-known/webfinger', (req, res) => {
+  if (isEigenWortel(req.query.resource, req)) {
+    res.type('application/jrd+json; charset=utf-8');
+    res.set('Cache-Control', 'public, max-age=300');
+    return res.send(JSON.stringify({
+      subject: baseUrl(req) + '/',
+      links: [
+        // Waar een home instance ondertekend een token mag ophalen (FEP-61cf
+        // stap 2/3). Host-niveau en niet per site: het token zegt WIE er binnen
+        // is, niet waar hij binnen mag -- dat besluit valt bij de poort.
+        { rel: 'http://purl.org/openwebauth/v1', href: baseUrl(req) + '/owa/token' },
+      ],
+    }));
+  }
   const user = webfingerGebruiker(req.query.resource);
   if (!user) return res.status(400).type('text/plain').send('bad resource');
Index: src/routes/openwebauth.js
===================================================================
--- src/routes/openwebauth.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
+++ src/routes/openwebauth.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
@@ -0,0 +1,125 @@
+/**
+ * OpenWebAuth (FEP-61cf), de drie plekken waar de stroom ons raakt.
+ *
+ *   POST /owa/login   de bezoeker geeft zijn adres, wij sturen hem naar huis
+ *   ALL  /owa/token   zijn server vraagt ondertekend om een token (stap 2/3)
+ *   GET  /owa/logout  weer anoniem
+ *
+ * Plus de middleware onderaan, die op ELK verzoek naar `?owt=` en `?zid=` kijkt.
+ *
+ * Zie OpenWebAuthService voor de stroom als geheel en voor de drie aanvallen
+ * die de FEP beschrijft.
+ */
+import express from 'express';
+import * as AP from '../services/ActivityPubService.js';
+import * as OWA from '../services/OpenWebAuthService.js';
+
+const router = express.Router();
+
+/** Waar de bezoeker weer uitkomt. Altijd binnen onze eigen origin. */
+function returnUrlFor(req, path) {
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  // Alleen een pad, nooit iets wat de bezoeker meegaf als volledige URL: dat is
+  // hoe een aanmeldformulier een open redirect wordt.
+  let p = String(path || '/');
+  if (!p.startsWith('/') || p.startsWith('//')) p = '/';
+  return base + p;
+}
+
+/**
+ * Stap 2/3: de home instance vraagt ondertekend om een token.
+ *
+ * GET EN POST, want de FEP zegt dat sommige home instances een POST met een
+ * willekeurig lijf sturen.
+ *
+ * De verificatie is niet nieuw geschreven: AP.verifyRequest() doet dit al voor
+ * de inbox, inclusief het vastpinnen van de sleutel op de herkomst van de actor
+ * (anders host je een document met andermans id naast je eigen sleutel), een
+ * replay-venster op Date, en een verplichte digest zodra er een lijf is. Eén
+ * implementatie van "is deze aanvraag echt van wie hij zegt".
+ */
+router.all('/owa/token', async (req, res) => {
+  let verified = null;
+  try { verified = await AP.verifyRequest(req); } catch { verified = null; }
+  if (!verified || !verified.id) {
+    return res.status(401).json({ success: false });
+  }
+  const pem = verified.publicKey && verified.publicKey.publicKeyPem;
+  if (!pem) return res.status(400).json({ success: false });
+
+  try {
+    const token = OWA.issueToken(verified.id);
+    // Versleuteld met ZIJN publieke sleutel: alleen de server die de bijbehorende
+    // prive-sleutel heeft kan hem lezen. Daarmee bewijst de teruggave dat de
+    // ondertekenaar ook echt die actor beheert, en niet alleen zijn document kent.
+    return res.json({ success: true, encrypted_token: OWA.encryptTokenFor(token, pem) });
+  } catch (e) {
+    console.warn('[owa] token uitgeven mislukte:', e && e.message);
+    return res.status(500).json({ success: false });
+  }
+});
+
+/**
+ * Stap 1: de bezoeker typt zijn adres en wij sturen hem naar zijn eigen server.
+ *
+ * We slaan hier NIETS op over wie hij zegt te zijn. Dat is opzet: pas het
+ * ingewisselde token bepaalt de identiteit (zie de impersonatie-aanval in de
+ * FEP). Een `zid`/handle is niet meer dan een routeringshint.
+ */
+router.post('/owa/login', async (req, res) => {
+  const terug = returnUrlFor(req, req.body && req.body.next);
+  const found = await OWA.discoverRedirectEndpoint(req.body && req.body.handle);
+  if (!found) {
+    const u = new URL(terug);
+    u.searchParams.set('owa_error', '1');
+    return res.redirect(u.toString());
+  }
+  return res.redirect(OWA.buildRedirect(found.endpoint, terug));
+});
+
+/** Weer anoniem. Raakt een eventuele lokale sessie niet aan. */
+router.get('/owa/logout', (req, res) => {
+  if (req.session) delete req.session.owa;
+  res.redirect(returnUrlFor(req, req.query && req.query.next));
+});
+
+/**
+ * Op elk verzoek: is er een token ingewisseld, of wil iemand de stroom starten?
+ *
+ * `owt` BEPAALT de identiteit. `zid` start hooguit de stroom en wordt verder
+ * genegeerd -- dat onderscheid IS de impersonatie-verdediging uit de FEP.
+ */
+export function owaMiddleware(req, res, next) {
+  if (req.method !== 'GET' || !req.query) return next();
+
+  if (req.query.owt) {
+    let actor = null;
+    try { actor = OWA.redeemToken(req.query.owt); } catch { actor = null; }
+    if (actor) req.session.owa = { actor, at: Date.now() };
+    // Het token uit de URL halen: hij is toch al opgebruikt, en zo blijft hij
+    // niet in de geschiedenis, de titelbalk of een gedeelde link staan.
+    const u = new URL(req.originalUrl, process.env.PUBLIC_BASE_URL || 'https://localhost');
+    u.searchParams.delete('owt');
+    u.searchParams.delete('zid');
+    return res.redirect(u.pathname + (u.search || ''));
+  }
+
+  if (req.query.zid && !OWA.guestActor(req)) {
+    const u = new URL(req.originalUrl, process.env.PUBLIC_BASE_URL || 'https://localhost');
+    u.searchParams.delete('zid');
+    const terug = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '') + u.pathname + (u.search || '');
+    // Geen await in een middleware-keten die verder synchroon is: bij een fout
+    // gaat de bezoeker gewoon door naar de pagina, alleen zonder inlog.
+    OWA.discoverRedirectEndpoint(req.query.zid)
+      .then((found) => {
+        if (found) return res.redirect(OWA.buildRedirect(found.endpoint, terug));
+        return res.redirect(terug);
+      })
+      .catch(() => res.redirect(terug));
+    return;
+  }
+
+  return next();
+}
+
+export default router;
Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision 9da8027b9243dfb7e0896f681a2551b9a2a3d70b)
+++ src/routes/posts.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
@@ -25,4 +25,5 @@
 import { verifyBlob } from '../services/CryptoBox.js';
 import { postEntry } from '../services/PostAccessService.js';
+import * as OWA from '../services/OpenWebAuthService.js';
 import MusicMeta from '../services/MusicMeta.js';
 import { mediaDir } from '../config/paths.js';
@@ -702,5 +703,5 @@
 // tekst op, want wat niet gerenderd wordt kan ook niet lekken.
 function readerItems(site, rows, req) {
-  const viewer = { user: req.session?.user || null, site, unlockedSlug: null };
+  const viewer = OWA.viewerFor(req, site, { unlockedSlug: null });
   return rows.map((post) => ({
     post,
@@ -1589,5 +1590,8 @@
   // Anonymous visitors get a clean login gate instead of the content (the title/
   // teaser may still appear elsewhere as a teaser).
-  if (post.fan_only && !(req.session && req.session.user)) {
+  // Een bezoeker die via OpenWebAuth bewees @iemand@ergens te zijn EN deze site
+  // volgt, is precies wie fan_only bedoelde. Die hoeft geen poort te zien.
+  const _fediVolger = OWA.isFollowerOf(site.slug, OWA.guestActor(req));
+  if (post.fan_only && !(req.session && req.session.user) && !_fediVolger) {
     // Same Newer/Older navigation as on a normal post, so the visitor doesn't get
     // stuck on the fan gate but can keep browsing.
@@ -1598,4 +1602,5 @@
       fgTitle: post.title || '',
       fgNext: (res.locals.siteUrlBase || '') + '/' + post.slug,
+      owaError: !!(req.query && req.query.owa_error),
       newerPost,
       olderPost,
Index: src/server.js
===================================================================
--- src/server.js	(revision 9da8027b9243dfb7e0896f681a2551b9a2a3d70b)
+++ src/server.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
@@ -66,4 +66,5 @@
 import ogRoutes from './routes/og.js';
 import apRoutes from './routes/activitypub.js';
+import owaRoutes, { owaMiddleware } from './routes/openwebauth.js';
 import oauthRoutes from './routes/oauth.js';
 import { apWants, startDeliveryWorker, selfHealTimeline, migrateReactions } from './services/ActivityPubService.js';
@@ -311,4 +312,10 @@
 // ActivityPub: WebFinger + /ap/* (site-agnostic, resolves the site by slug).
 app.use(apRoutes);
+// OpenWebAuth (FEP-61cf): het token-endpoint en het inlogformulier.
+app.use(owaRoutes);
+// En op elk GET-verzoek kijken of er een token wordt ingewisseld (?owt=) of een
+// stroom gestart (?zid=). Na de sessie, want het resultaat gaat IN de sessie;
+// voor de pagina's, want een poort verderop moet de uitkomst al kunnen zien.
+app.use(owaMiddleware);
 // ActivityPub C2S: OAuth 2.0 (native/web clients). Site-agnostic; the consent
 // screen picks which site the token can post as.
Index: src/services/OpenWebAuthService.js
===================================================================
--- src/services/OpenWebAuthService.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
+++ src/services/OpenWebAuthService.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
@@ -0,0 +1,199 @@
+/**
+ * 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();
+}
+
+// ── 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,
+};
Index: src/services/PostAccessService.js
===================================================================
--- src/services/PostAccessService.js	(revision 9da8027b9243dfb7e0896f681a2551b9a2a3d70b)
+++ src/services/PostAccessService.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
@@ -44,9 +44,12 @@
  *
  * @param post     de rij uit `posts`
- * @param viewer   { user, site, unlockedSlug } -- unlockedSlug is de slug uit
- *                 een vers ?u=-bewijs van /paid/unlock, al geverifieerd door de
- *                 aanroeper. Deze module doet geen crypto.
+ * @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 } = {}) {
+export function postAccess(post, { user = null, site = null, unlockedSlug = null, isFollower = false } = {}) {
   if (!post) return 'forbidden';
 
@@ -62,5 +65,13 @@
 
   if (post.paid && String(unlockedSlug || '') !== String(post.slug)) return 'paid';
-  if (post.fan_only && !user) return 'fan';
+  // `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';
 }
Index: src/services/i18n.js
===================================================================
--- src/services/i18n.js	(revision 9da8027b9243dfb7e0896f681a2551b9a2a3d70b)
+++ src/services/i18n.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
@@ -890,6 +890,6 @@
     'epk.recent': 'Recent',
     'fgate.title': 'Alleen voor vrienden',
-    'fgate.sub': 'Dit is exclusief voor ingelogde vrienden. Log in om het te bekijken.',
-    'fgate.login': 'Inloggen / aanmelden', 'read.open': 'Reacties en waarderingen',
+    'fgate.sub': 'Dit bericht is voor wie deze site volgt.',
+    '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',
@@ -1896,6 +1896,6 @@
     'epk.recent': 'Recent',
     'fgate.title': 'Friends only',
-    'fgate.sub': 'This is exclusive to logged-in friends. Log in to view it.',
-    'fgate.login': 'Log in / sign up', 'read.open': 'Replies and reactions',
+    'fgate.sub': 'This post is for people who follow this site.',
+    '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',
@@ -2902,6 +2902,6 @@
     'epk.recent': 'Aktuell',
     'fgate.title': 'Nur für Freunde',
-    'fgate.sub': 'Dies ist exklusiv für eingeloggte Freunde. Melde dich an, um es anzusehen.',
-    'fgate.login': 'Anmelden / Registrieren', 'read.open': 'Antworten und Reaktionen',
+    'fgate.sub': 'Dieser Beitrag ist für alle, die dieser Seite folgen.',
+    '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',
Index: src/views/pages/fan-gate.ejs
===================================================================
--- src/views/pages/fan-gate.ejs	(revision 9da8027b9243dfb7e0896f681a2551b9a2a3d70b)
+++ src/views/pages/fan-gate.ejs	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
@@ -12,5 +12,27 @@
     <% if (typeof fgTitle !== 'undefined' && fgTitle) { %><p class="fg-which">"<%= fgTitle %>"</p><% } %>
     <p class="fg-sub"><%= t('fgate.sub') %></p>
-    <p><a class="fg-btn" href="/auth/login?next=<%= encodeURIComponent(fgNext || '/') %>"><%= t('fgate.login') %></a></p>
+
+    <%# De hoofdweg: bewijs bij je EIGEN server wie je bent (OpenWebAuth,
+        FEP-61cf). Dit is de vraag die fan_only altijd al stelde -- volg je deze
+        site? -- in plaats van "heb je hier een account", wat juist de mensen
+        buitensloot voor wie de poort openstond. Geen account hier, geen
+        wachtwoord hier, geen cookie van een derde. %>
+    <form class="fg-owa" method="post" action="/owa/login">
+      <input type="hidden" name="next" value="<%= fgNext || '/' %>">
+      <label class="fg-label" for="fg-handle"><%= t('fgate.owa_label') %></label>
+      <div class="fg-row">
+        <input class="fg-input" id="fg-handle" name="handle" type="text" inputmode="email"
+               autocomplete="username" spellcheck="false" placeholder="@jij@jouwserver.nl" required>
+        <button class="fg-btn" type="submit"><%= t('fgate.owa_go') %></button>
+      </div>
+      <p class="fg-hint"><%= t('fgate.owa_hint') %></p>
+      <% if (typeof owaError !== 'undefined' && owaError) { %>
+        <p class="fg-error"><%= t('fgate.owa_failed') %></p>
+      <% } %>
+    </form>
+
+    <%# De lokale inlog blijft, maar als tweede: die is voor wie hier echt een
+        account heeft, en dat is de uitzondering. %>
+    <p class="fg-alt"><a href="/auth/login?next=<%= encodeURIComponent(fgNext || '/') %>"><%= t('fgate.login') %></a></p>
   </div>
 </section>
@@ -26,4 +48,13 @@
   .fg-which { font-style: italic; opacity: .8; margin: 0 0 12px; }
   .fg-sub { opacity: .85; line-height: 1.6; margin: 0 0 20px; }
-  .fg-btn { display: inline-block; padding: 12px 22px; border-radius: 10px; background: var(--accent,#6b8f71); color: #fff; text-decoration: none; font-weight: 600; }
+  .fg-btn { display: inline-block; padding: 12px 22px; border-radius: 10px; background: var(--accent,#6b8f71); color: #fff; text-decoration: none; font-weight: 600; border: 0; font-family: inherit; font-size: 1rem; cursor: pointer; }
+  .fg-owa { margin: 0 0 18px; text-align: left; }
+  .fg-label { display: block; font-size: .85rem; opacity: .8; margin-bottom: 6px; }
+  .fg-row { display: flex; gap: 8px; flex-wrap: wrap; }
+  .fg-input { flex: 1 1 12rem; min-width: 0; padding: 12px 14px; border-radius: 10px;
+              border: 1px solid rgba(128,128,128,.35); background: transparent; color: inherit;
+              font-family: inherit; font-size: 1rem; }
+  .fg-hint { font-size: .8rem; opacity: .65; margin: 8px 0 0; line-height: 1.5; }
+  .fg-error { font-size: .85rem; margin: 8px 0 0; color: #d66; }
+  .fg-alt { margin: 0; font-size: .85rem; opacity: .7; }
 </style>
Index: test/openwebauth-http.test.js
===================================================================
--- test/openwebauth-http.test.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
+++ test/openwebauth-http.test.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
@@ -0,0 +1,133 @@
+// OpenWebAuth over HTTP: de vindbaarheid, de poort van het token-endpoint, en
+// de impersonatie-verdediging.
+//
+// Die laatste is de belangrijkste toets in dit bestand. De FEP beschrijft hem
+// zo: Mallory maakt een link naar ONZE site met ?zid=bob@elders, klikt hem zelf,
+// en komt terug met een token dat MALLORY zegt. Wie `zid` gelooft in plaats van
+// het ingewisselde `owt`, laat Mallory als Bob binnen.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import express from 'express';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://test.example';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const OWA = await import('../src/services/OpenWebAuthService.js');
+const apRoutes = (await import('../src/routes/activitypub.js')).default;
+const owaMod = await import('../src/routes/openwebauth.js');
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'u1', 'u1@t', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary, created_at) VALUES (?,?,?,?,1,?)')
+  .run('s1', 'kid', 'kid', 'u1', '2026-01-01 00:00:00');
+
+const ACTOR = 'https://elders.example/users/mee';
+const SLACHTOFFER = 'https://elders.example/users/bob';
+
+// Eén app met een sessie-nep: we willen hier de middleware toetsen, niet
+// express-session.
+const app = express();
+app.use((req, _res, next) => { req.session = app.locals.sessie; next(); });
+app.use(apRoutes);
+app.use(owaMod.default);
+app.use(owaMod.owaMiddleware);
+app.get('/een-bericht', (req, res) => res.json({ owa: (req.session && req.session.owa) || null }));
+
+const server = app.listen(0);
+await new Promise((r) => server.once('listening', r));
+const port = server.address().port;
+test.after(() => server.close());
+
+const haal = (pad, opts) => fetch(`http://127.0.0.1:${port}${pad}`, { redirect: 'manual', ...opts });
+
+// ── vindbaarheid ──────────────────────────────────────────────────────────
+
+test('de wortel wijst een home instance naar ons token-endpoint', async () => {
+  const r = await haal('/.well-known/webfinger?resource=' + encodeURIComponent('https://test.example/'));
+  assert.equal(r.status, 200);
+  const jrd = await r.json();
+  const link = jrd.links.find((l) => l.rel === 'http://purl.org/openwebauth/v1');
+  assert.ok(link, 'de rel uit de FEP staat erin');
+  assert.equal(link.href, 'https://test.example/owa/token');
+});
+
+test('en een ACTOR-uri blijft een 400, zoals eerder besloten', async () => {
+  // Dit is geen detail: test/webfinger-bare-host.test.js legt het vast, en de
+  // wortel-uitzondering mocht die keuze niet stiekem omdraaien.
+  const r = await haal('/.well-known/webfinger?resource=' + encodeURIComponent('https://test.example/ap/users/kid'));
+  assert.equal(r.status, 400);
+});
+
+test('een wortel van een ANDERE host is niet van ons', async () => {
+  const r = await haal('/.well-known/webfinger?resource=' + encodeURIComponent('https://ergens-anders.example/'));
+  assert.equal(r.status, 400);
+});
+
+// ── het token-endpoint ────────────────────────────────────────────────────
+
+test('zonder handtekening geen token', async () => {
+  // De hele waarde van dit endpoint zit in de handtekening: wie hem niet zet,
+  // bewijst niets en krijgt niets.
+  for (const method of ['GET', 'POST']) {
+    const r = await haal('/owa/token', { method });
+    assert.equal(r.status, 401, method);
+    assert.deepEqual(await r.json(), { success: false });
+  }
+});
+
+test('een verzonnen handtekening ook niet', async () => {
+  const r = await haal('/owa/token', {
+    headers: { signature: 'keyId="https://elders.example/users/mee#main-key",signature="bm9wZQ=="' },
+  });
+  assert.equal(r.status, 401);
+});
+
+// ── impersonatie ──────────────────────────────────────────────────────────
+
+test('?owt= bepaalt wie je bent', async () => {
+  app.locals.sessie = {};
+  const t = OWA.issueToken(ACTOR);
+  const r = await haal('/een-bericht?owt=' + encodeURIComponent(t));
+  assert.equal(r.status, 302, 'het token wordt uit de URL gehaald');
+  assert.equal(r.headers.get('location'), '/een-bericht', 'en laat niets achter in de adresbalk');
+  assert.equal(app.locals.sessie.owa.actor, ACTOR);
+});
+
+test('?zid= bepaalt NIETS -- dit is de impersonatie-aanval uit de FEP', async () => {
+  app.locals.sessie = {};
+  // Mallory wijst met zid naar Bob. Er mag hier geen identiteit uit ontstaan.
+  const r = await haal('/een-bericht?zid=' + encodeURIComponent('bob@elders.example'));
+  assert.ok(r.status === 302 || r.status === 200, 'hooguit een doorverwijzing');
+  assert.equal(app.locals.sessie.owa, undefined, 'zid logt niemand in');
+});
+
+test('en zid naast een geldig owt verandert de uitkomst niet', async () => {
+  app.locals.sessie = {};
+  const t = OWA.issueToken(ACTOR);                       // dit is Mallory's eigen token
+  const r = await haal(`/een-bericht?owt=${encodeURIComponent(t)}&zid=${encodeURIComponent('bob@elders.example')}`);
+  assert.equal(r.status, 302);
+  assert.equal(app.locals.sessie.owa.actor, ACTOR, 'het token wint, niet de claim');
+  assert.notEqual(app.locals.sessie.owa.actor, SLACHTOFFER);
+  assert.equal(r.headers.get('location'), '/een-bericht', 'en zid gaat ook de URL uit');
+});
+
+test('een opgebruikt token logt niemand in', async () => {
+  app.locals.sessie = {};
+  const t = OWA.issueToken(ACTOR);
+  await haal('/een-bericht?owt=' + encodeURIComponent(t));
+  app.locals.sessie = {};
+  await haal('/een-bericht?owt=' + encodeURIComponent(t));
+  assert.equal(app.locals.sessie.owa, undefined, 'de tweede keer is hij niets waard');
+});
+
+// ── uitloggen ─────────────────────────────────────────────────────────────
+
+test('uitloggen laat een lokale sessie met rust', async () => {
+  app.locals.sessie = { owa: { actor: ACTOR }, user: { id: 'u1' } };
+  await haal('/owa/logout');
+  assert.equal(app.locals.sessie.owa, undefined, 'de gast is weg');
+  assert.deepEqual(app.locals.sessie.user, { id: 'u1' }, 'de lokale gebruiker niet');
+});
Index: test/openwebauth.test.js
===================================================================
--- test/openwebauth.test.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
+++ test/openwebauth.test.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
@@ -0,0 +1,186 @@
+// OpenWebAuth (FEP-61cf), de target-kant.
+//
+// Dit is een AUTHENTICATIEPAD, dus de toetsen hier staan scherper dan elders:
+// niet alleen "werkt de gelukkige route", maar met name de drie aanvallen die de
+// FEP zelf beschrijft -- impersonatie via ?zid=, open redirect via het
+// ontdekte endpoint, en tokens die blijven liggen.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import crypto from 'crypto';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://test.example';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const OWA = await import('../src/services/OpenWebAuthService.js');
+
+const ACTOR = 'https://elders.example/users/mee';
+const ANDER = 'https://elders.example/users/iemand';
+
+// ── tokens ────────────────────────────────────────────────────────────────
+
+test('een token wijst naar de actor die hem kreeg', () => {
+  const t = OWA.issueToken(ACTOR);
+  assert.equal(OWA.redeemToken(t), ACTOR);
+});
+
+test('een token werkt precies ÉÉN keer', () => {
+  // Anders is een token dat ergens in een log of een history-item blijft hangen
+  // een sleutel die blijft werken.
+  const t = OWA.issueToken(ACTOR);
+  assert.equal(OWA.redeemToken(t), ACTOR);
+  assert.equal(OWA.redeemToken(t), null, 'de tweede keer is niets meer waard');
+});
+
+test('een verzonnen token levert niemand op', () => {
+  assert.equal(OWA.redeemToken('zomaar-wat'), null);
+  assert.equal(OWA.redeemToken(''), null);
+  assert.equal(OWA.redeemToken(null), null);
+});
+
+test('een token verloopt, en verlopen aanbieden verbrandt hem ook', () => {
+  const t = OWA.issueToken(ACTOR, Date.now() - (OWA.TOKEN_TTL_MS + 1000));
+  assert.equal(OWA.redeemToken(t), null, 'te oud');
+  // En hij is weg: opnieuw aanbieden binnen de tijd kan niet alsnog lukken.
+  const rij = db.prepare('SELECT 1 FROM owa_tokens WHERE token = ?').get(t);
+  assert.equal(rij, undefined, 'een aangeboden token blijft niet liggen');
+});
+
+test('oude tokens worden opgeruimd (de DoS uit de FEP)', () => {
+  db.prepare('DELETE FROM owa_tokens').run();
+  const oud = Date.now() - (OWA.TOKEN_TTL_MS + 60_000);
+  for (let i = 0; i < 5; i++) {
+    db.prepare('INSERT INTO owa_tokens (token, actor_uri, created_at) VALUES (?,?,?)')
+      .run('oud-' + i, ACTOR, oud);
+  }
+  OWA.issueToken(ACTOR);            // elke uitgifte veegt
+  const over = db.prepare('SELECT COUNT(*) AS n FROM owa_tokens').get().n;
+  assert.equal(over, 1, 'alleen de verse blijft staan');
+});
+
+// ── versleuteling ─────────────────────────────────────────────────────────
+
+test('het token is alleen leesbaar voor wie de privésleutel heeft', () => {
+  // De hele reden dat deze stap bestaat: de ondertekenaar bewijst dat hij de
+  // actor BEHEERT, niet alleen dat hij zijn document kent.
+  const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+  const pem = publicKey.export({ type: 'spki', format: 'pem' });
+  const token = OWA.issueToken(ACTOR);
+  const versleuteld = OWA.encryptTokenFor(token, pem);
+
+  assert.ok(!versleuteld.includes('='), 'base64url zonder padding, zoals de FEP zegt');
+  assert.ok(!/[+/]/.test(versleuteld), 'en URL-veilig');
+
+  const terug = crypto.privateDecrypt(
+    { key: privateKey, padding: crypto.constants.RSA_PKCS1_PADDING },
+    Buffer.from(versleuteld.replace(/-/g, '+').replace(/_/g, '/'), 'base64'),
+  ).toString('utf8');
+  assert.equal(terug, token);
+
+  // Een ANDERE sleutel komt er niet bij -- maar LET OP HOE dat eruitziet, want
+  // de voor de hand liggende toets (assert.throws) is hier fout.
+  //
+  // PKCS#1 v1.5 gooit bij een verkeerde sleutel geen fout: OpenSSL 3 doet aan
+  // "implicit rejection" en geeft afgeleide onzin terug in plaats van te falen,
+  // juist zodat een aanvaller niet aan het foutgedrag kan aflezen of zijn gok
+  // klopte (Bleichenbacher/Marvin). Gemeten: 200 vreemde sleutels, 0 fouten,
+  // 200 keer bytes -- en 0 keer het token.
+  //
+  // De eigenschap die telt is dus niet "het knalt" maar "er komt iets anders
+  // uit". Wat de home instance daarna terugstuurt matcht geen enkel opgeslagen
+  // token, en de inlog mislukt gewoon.
+  const ruw = Buffer.from(versleuteld.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
+  for (let i = 0; i < 5; i++) {
+    const vreemde = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey;
+    let uit = null;
+    try {
+      uit = crypto.privateDecrypt({ key: vreemde, padding: crypto.constants.RSA_PKCS1_PADDING }, ruw).toString('utf8');
+    } catch { uit = null; }                     // een fout mag, maar is niet de regel
+    assert.notEqual(uit, token, 'andermans sleutel levert nooit het token');
+  }
+});
+
+// ── adressen lezen ────────────────────────────────────────────────────────
+
+test('een adres mag op vier manieren geschreven worden', () => {
+  for (const vorm of ['mee@elders.example', '@mee@elders.example', 'acct:mee@elders.example', '  @mee@elders.example  ']) {
+    const h = OWA.parseHandle(vorm);
+    assert.ok(h, vorm);
+    assert.equal(h.acct, 'mee@elders.example');
+  }
+});
+
+test('en onzin is geen adres', () => {
+  for (const vorm of ['', null, 'geen-apenstaartje', 'twee@@apen', 'met/schuine@streep', 'a@b@c']) {
+    assert.equal(OWA.parseHandle(vorm), null, JSON.stringify(vorm));
+  }
+});
+
+// ── open redirect ─────────────────────────────────────────────────────────
+
+test('een endpoint op een ANDERE host wordt geweigerd', async () => {
+  // De aanval: een server antwoordt op webfinger met een redirect-endpoint dat
+  // ergens anders wijst, en ons inlogformulier wordt een doorgeefluik.
+  const nep = async () => ({
+    ok: true,
+    json: async () => ({ links: [{ rel: OWA.REL_REDIRECT, href: 'https://kwaadaardig.example/magic' }] }),
+  });
+  const r = await OWA.discoverRedirectEndpoint('mee@elders.example', { fetchImpl: nep });
+  assert.equal(r, null, 'andere host dan het ingetypte adres → niet doen');
+});
+
+test('een endpoint op de eigen host is prima', async () => {
+  const nep = async () => ({
+    ok: true,
+    json: async () => ({ links: [{ rel: OWA.REL_REDIRECT, href: 'https://elders.example/owa/hier' }] }),
+  });
+  const r = await OWA.discoverRedirectEndpoint('mee@elders.example', { fetchImpl: nep });
+  assert.equal(r.endpoint, 'https://elders.example/owa/hier');
+});
+
+test('zonder webfinger-link vallen we terug op /magic, op dezelfde host', async () => {
+  const nep = async () => ({ ok: false, json: async () => ({}) });
+  const r = await OWA.discoverRedirectEndpoint('mee@elders.example', { fetchImpl: nep });
+  assert.equal(r.endpoint, 'https://elders.example/magic', 'Hubzilla en (streams) doen het zo');
+});
+
+test('de redirect draagt owa=1 en een hex-bdest', () => {
+  const terug = 'https://test.example/een-bericht';
+  const url = new URL(OWA.buildRedirect('https://elders.example/magic', terug));
+  assert.equal(url.searchParams.get('owa'), '1');
+  const bdest = url.searchParams.get('bdest');
+  assert.match(bdest, /^[0-9a-f]+$/i, 'hexadecimaal, zoals de FEP zegt');
+  assert.equal(Buffer.from(bdest, 'hex').toString('utf8'), terug);
+});
+
+// ── wie is er binnen ──────────────────────────────────────────────────────
+
+test('volgerschap is de vraag die fan_only stelt', () => {
+  db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+    .run('u-owa', 'u-owa', 'u@t', 'x', 'god');
+  db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)')
+    .run('s-owa', 'kid', 'kid', 'u-owa');
+  db.prepare('INSERT INTO ap_followers (slug, actor_uri) VALUES (?,?)').run('kid', ACTOR);
+
+  assert.equal(OWA.isFollowerOf('kid', ACTOR), true);
+  assert.equal(OWA.isFollowerOf('kid', ANDER), false, 'een vreemde volgt niet');
+  assert.equal(OWA.isFollowerOf('kid', null), false, 'en niemand al helemaal niet');
+  assert.equal(OWA.isFollowerOf('andere-site', ACTOR), false, 'volgen doe je een SITE, niet de server');
+});
+
+test('een sessie zonder bewijs levert geen actor', () => {
+  assert.equal(OWA.guestActor(null), null);
+  assert.equal(OWA.guestActor({ session: {} }), null);
+  assert.equal(OWA.guestActor({ session: { owa: {} } }), null);
+  assert.equal(OWA.guestActor({ session: { owa: { actor: ACTOR } } }), ACTOR);
+});
+
+test('viewerFor zet het bewijs om in een besluitbare vorm', () => {
+  const req = { session: { owa: { actor: ACTOR } } };
+  const v = OWA.viewerFor(req, { slug: 'kid' });
+  assert.equal(v.fediActor, ACTOR);
+  assert.equal(v.isFollower, true);
+  assert.equal(v.user, null, 'een gast is geen lokale gebruiker');
+});
Index: test/post-access.test.js
===================================================================
--- test/post-access.test.js	(revision 9da8027b9243dfb7e0896f681a2551b9a2a3d70b)
+++ test/post-access.test.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
@@ -77,4 +77,28 @@
 });
 
+// ── de fanpoort stelt eindelijk de juiste vraag ───────────────────────────
+// fan_only betekende altijd al "mijn volgers op de fediverse", maar de poort
+// vroeg om een lokaal account. isFollower komt uit OpenWebAuthService: een
+// bezoeker die via FEP-61cf bewees @iemand@ergens te zijn en die deze site volgt.
+
+test('een bewezen volger komt door de fanpoort', () => {
+  const f = post({ fan_only: 1 });
+  assert.equal(postAccess(f, { user: VREEMDE, site: SITE }), 'fan', 'zonder bewijs nog steeds dicht');
+  assert.equal(postAccess(f, { user: VREEMDE, site: SITE, isFollower: true }), 'full',
+    'mét bewijs open -- geen account, geen wachtwoord, wel een volger');
+});
+
+test('maar volgen koopt geen betaald bericht', () => {
+  // De volgorde blijft: betaald wint van fan-only, en volgerschap is geen geld.
+  const b = post({ paid: 1, fan_only: 1 });
+  assert.equal(postAccess(b, { user: VREEMDE, site: SITE, isFollower: true }), 'paid');
+  assert.equal(postAccess(b, { user: VREEMDE, site: SITE, isFollower: true, unlockedSlug: 'hallo' }), 'full',
+    'betaald EN volger komt er wel doorheen');
+});
+
+test('volgen doet niets bij een concept', () => {
+  assert.equal(postAccess(post({ status: 'draft' }), { user: VREEMDE, site: SITE, isFollower: true }), 'forbidden');
+});
+
 test('de eigenaar loopt niet tegen zijn eigen muur op', () => {
   assert.equal(postAccess(post({ paid: 1, fan_only: 1 }), { user: OWNER, site: SITE }), 'full');
