Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision 7cf8144b1073d0f9d019acfcd4ee74b4e6a4dccd)
+++ 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,
