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 7cf8144b1073d0f9d019acfcd4ee74b4e6a4dccd)
+++ 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 7cf8144b1073d0f9d019acfcd4ee74b4e6a4dccd)
+++ 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',
