Index: src/services/PostAccessService.js
===================================================================
--- src/services/PostAccessService.js	(revision b1b765257cdbf35042bd15d214253011fbbd295e)
+++ src/services/PostAccessService.js	(revision b1b765257cdbf35042bd15d214253011fbbd295e)
@@ -0,0 +1,71 @@
+/**
+ * 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 } -- unlockedSlug is de slug uit
+ *                 een vers ?u=-bewijs van /paid/unlock, al geverifieerd door de
+ *                 aanroeper. Deze module doet geen crypto.
+ */
+export function postAccess(post, { user = null, site = null, unlockedSlug = null } = {}) {
+  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';
+  if (post.fan_only && !user) return 'fan';
+  return 'full';
+}
+
+/** Handig voor sjablonen: mag de bezoeker de echte inhoud zien? */
+export function canReadBody(post, viewer) { return postAccess(post, viewer) === 'full'; }
+
+export default { postAccess, canReadBody, paidTeaser };
Index: test/post-access.test.js
===================================================================
--- test/post-access.test.js	(revision b1b765257cdbf35042bd15d214253011fbbd295e)
+++ test/post-access.test.js	(revision b1b765257cdbf35042bd15d214253011fbbd295e)
@@ -0,0 +1,97 @@
+// Wie mag wat zien van een bericht -- op ÉÉN plek.
+//
+// Het besluit zat alleen in de postroute, waar het een andere PAGINA rendert
+// (paid-gate/fan-gate). De tijdlijn gaat hele berichten tonen en heeft hetzelfde
+// besluit nodig als een stuk. Twee plekken die allebei zelf bepalen wie een
+// betaalde tekst mag lezen, is een lek dat op een gelegenheid wacht -- vandaar
+// deze module, en vandaar dat de toetsen hier scherper staan dan elders.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://test.example';
+
+const dbMod = await import('../src/config/database.js');
+dbMod.initializeDatabase();
+const { postAccess, canReadBody, paidTeaser } = await import('../src/services/PostAccessService.js');
+
+const SITE = { id: 's1', slug: 'demo', owner_id: 'u1' };
+const OWNER = { id: 'u1', role: 'god' };
+const VREEMDE = null;
+const INGELOGD = { id: 'u2', role: 'user' };
+
+const post = (extra = {}) => ({
+  id: 'p1', slug: 'hallo', status: 'published', site_id: 's1', author_id: 'u1',
+  content: '<p>eerste alinea</p><p>tweede alinea</p>', ...extra,
+});
+
+test('een gewoon bericht is voor iedereen', () => {
+  assert.equal(postAccess(post(), { user: VREEMDE, site: SITE }), 'full');
+  assert.equal(canReadBody(post(), { user: VREEMDE, site: SITE }), true);
+});
+
+test('een concept is van de maker, en van niemand anders', () => {
+  const concept = post({ status: 'draft' });
+  assert.equal(postAccess(concept, { user: VREEMDE, site: SITE }), 'forbidden');
+  assert.equal(postAccess(concept, { user: INGELOGD, site: SITE }), 'forbidden');
+  assert.equal(postAccess(concept, { user: OWNER, site: SITE }), 'full', 'de eigenaar ziet zijn eigen concept');
+});
+
+test('fan-only vraagt om inloggen, niet om betalen', () => {
+  const f = post({ fan_only: 1 });
+  assert.equal(postAccess(f, { user: VREEMDE, site: SITE }), 'fan');
+  assert.equal(postAccess(f, { user: INGELOGD, site: SITE }), 'full', 'ingelogd is genoeg');
+});
+
+test('betaald blijft betaald tot er een geldig bewijs ligt', () => {
+  const b = post({ paid: 1 });
+  assert.equal(postAccess(b, { user: VREEMDE, site: SITE }), 'paid');
+  assert.equal(postAccess(b, { user: INGELOGD, site: SITE }), 'paid',
+    'inloggen koopt niets: een Klonkt-account is geen supporter');
+  assert.equal(postAccess(b, { user: VREEMDE, site: SITE, unlockedSlug: 'hallo' }), 'full');
+});
+
+test('een bewijs voor een ANDER bericht opent dit bericht niet', () => {
+  // De gevaarlijke vorm: één keer betalen en dan de hele site lezen.
+  const b = post({ paid: 1 });
+  assert.equal(postAccess(b, { user: VREEMDE, site: SITE, unlockedSlug: 'een-ander' }), 'paid');
+  assert.equal(postAccess(b, { user: VREEMDE, site: SITE, unlockedSlug: '' }), 'paid');
+  assert.equal(postAccess(b, { user: VREEMDE, site: SITE, unlockedSlug: null }), 'paid');
+});
+
+test('betaald WINT van fan-only, en die volgorde is geen detail', () => {
+  // Een bericht dat allebei is gaat open met een passkey, niet met een login.
+  // Andersom belandt een anonieme supporter op het inlogscherm en ziet hij de
+  // ontgrendelknop nooit -- de reden dat deze volgorde in de route stond.
+  const beide = post({ paid: 1, fan_only: 1 });
+  assert.equal(postAccess(beide, { user: VREEMDE, site: SITE }), 'paid');
+  assert.equal(postAccess(beide, { user: INGELOGD, site: SITE }), 'paid',
+    'ook ingelogd: de betaalmuur staat vooraan');
+
+  // TWEE POORTEN ACHTER ELKAAR, en dat is de bestaande werking -- overgenomen
+  // uit de route, niet hier bedacht. Wie betaalt maar niet ingelogd is, staat
+  // na het ontgrendelen alsnog voor de fanpoort:
+  assert.equal(postAccess(beide, { user: VREEMDE, site: SITE, unlockedSlug: 'hallo' }), 'fan');
+  // Betalen ÉN ingelogd komt er wel doorheen.
+  assert.equal(postAccess(beide, { user: INGELOGD, site: SITE, unlockedSlug: 'hallo' }), 'full');
+});
+
+test('de eigenaar loopt niet tegen zijn eigen muur op', () => {
+  assert.equal(postAccess(post({ paid: 1, fan_only: 1 }), { user: OWNER, site: SITE }), 'full');
+});
+
+test('geen bericht is geen toegang', () => {
+  assert.equal(postAccess(null, { user: OWNER, site: SITE }), 'forbidden');
+  assert.equal(postAccess(undefined, {}), 'forbidden');
+});
+
+test('de teaser lekt nooit voorbij de eerste alinea', () => {
+  // Dit is de hele belofte van een betaalmuur: wat hierna komt, komt er niet uit.
+  const t = paidTeaser(post({ paid: 1 }));
+  assert.match(t, /eerste alinea/);
+  assert.ok(!t.includes('tweede'), 'de tweede alinea blijft binnen');
+});
+
+test('een expliciete excerpt gaat voor', () => {
+  assert.equal(paidTeaser(post({ excerpt: 'zelf geschreven' })), 'zelf geschreven');
+});
