Index: src/services/PostAccessService.js
===================================================================
--- src/services/PostAccessService.js	(revision b1b765257cdbf35042bd15d214253011fbbd295e)
+++ src/services/PostAccessService.js	(revision 8b65e67e700ea809fa6f1d5e556db13ed2a0696c)
@@ -69,3 +69,29 @@
 export function canReadBody(post, viewer) { return postAccess(post, viewer) === 'full'; }
 
-export default { postAccess, canReadBody, paidTeaser };
+/**
+ * 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/views/partials/post-body.ejs
===================================================================
--- src/views/partials/post-body.ejs	(revision 8b65e67e700ea809fa6f1d5e556db13ed2a0696c)
+++ src/views/partials/post-body.ejs	(revision 8b65e67e700ea809fa6f1d5e556db13ed2a0696c)
@@ -0,0 +1,58 @@
+<%
+// Het lijf van één bericht, IN de stroom.
+//
+// De poorten waren tot nu toe hele pagina's: paid-gate of fan-gate in plaats
+// van het bericht. Dat kan niet meer zodra de tijdlijn hele berichten toont --
+// dan moet een gesloten bericht zijn PLEK houden en ter plekke zeggen wat er
+// aan de hand is. Weglaten zou erger zijn dan tonen: dan zien een supporter en
+// een vreemde een andere tijdlijn, met andere buren, en klopt "vorige/volgende"
+// voor niemand meer.
+//
+// Het BESLUIT staat niet hier maar in PostAccessService, dezelfde die de
+// postroute gebruikt. Dit sjabloon kiest alleen de vorm. Twee plekken die
+// allebei zelf bepalen wie een betaalde tekst mag lezen, is een lek dat op een
+// gelegenheid wacht.
+//
+// Verwacht: post, access ('full'|'paid'|'fan'|'forbidden'), teaser, _base.
+var _acc = (typeof access !== 'undefined' && access) ? access : 'full';
+var _teaser = (typeof teaser !== 'undefined' && teaser) ? teaser : '';
+var _b = (typeof _base !== 'undefined' && _base) ? _base : '';
+var _href = _b + '/' + post.slug;
+%>
+
+<% if (_acc === 'full') { %>
+  <div class="post-content"><%- post.content_html %></div>
+
+<% } else if (_acc === 'paid') { %>
+  <%# Dezelfde woorden als de hele pagina (pgate.*), want het is dezelfde
+      boodschap -- alleen op een andere plek. Eigen teksten hier zouden na één
+      wijziging uiteen gaan lopen. %>
+  <div class="post-content post-content--gated pb-paid">
+    <% if (_teaser) { %><div class="pg-teaser"><p><%= _teaser %></p></div><% } %>
+    <div class="pb-gate">
+      <h2 class="pg-h2"><%= t('pgate.h') %></h2>
+      <p class="pg-sub">
+        <% if (post.paid_min_cents) { %><%= t('pgate.sub_cents', { eur: (post.paid_min_cents/100).toFixed(2) }) %><% } else { %><%= t('pgate.sub') %><% } %>
+      </p>
+      <%# De ontgrendeling zelf blijft op de eigen pagina: daar staat de
+          passkey-code (pageJs paid-gate) die hier niet geladen is. Eén knop die
+          ernaartoe gaat is eerlijker dan een halve poort in de stroom. %>
+      <p><a class="pg-btn" href="<%= _href %>"><%= t('pgate.unlock') %></a></p>
+    </div>
+  </div>
+
+<% } else if (_acc === 'fan') { %>
+  <div class="post-content post-content--gated pb-fan">
+    <% if (_teaser) { %><div class="pg-teaser"><p><%= _teaser %></p></div><% } %>
+    <div class="pb-gate">
+      <h2 class="pg-h2"><%= t('fgate.title') %></h2>
+      <p class="pg-sub"><%= t('fgate.sub') %></p>
+      <p><a class="fg-btn" href="/auth/login?next=<%= encodeURIComponent(_href) %>"><%= t('fgate.login') %></a></p>
+    </div>
+  </div>
+
+<% } else { %>
+  <%# 'forbidden' hoort de tijdlijn niet eens te bereiken: die haalt alleen
+      gepubliceerde berichten op. Staat er voor het geval een aanroeper ooit
+      iets anders doorgeeft -- dan liever niets dan een concept. %>
+<% } %>
Index: test/post-access.test.js
===================================================================
--- test/post-access.test.js	(revision b1b765257cdbf35042bd15d214253011fbbd295e)
+++ test/post-access.test.js	(revision 8b65e67e700ea809fa6f1d5e556db13ed2a0696c)
@@ -96,2 +96,44 @@
   assert.equal(paidTeaser(post({ excerpt: 'zelf geschreven' })), 'zelf geschreven');
 });
+
+// ── Wat de tijdlijn krijgt ────────────────────────────────────────────────
+// De tijdlijn toont hele berichten, dus een gesloten bericht moet zijn plek
+// houden ZONDER dat de tekst meereist. Dat is geen sjabloonkwestie: wat niet
+// gerenderd wordt, kan ook niet per ongeluk getoond worden.
+
+const { postEntry } = await import('../src/services/PostAccessService.js');
+const renderBody = (p) => `<div>GEHEIM: ${p.content}</div>`;
+
+test('een open bericht krijgt zijn lijf', () => {
+  const e = postEntry(post(), { user: VREEMDE, site: SITE }, { renderBody });
+  assert.equal(e.access, 'full');
+  assert.match(e.content_html, /GEHEIM/);
+  assert.equal(e.teaser, null, 'geen teaser NAAST het lijf');
+});
+
+test('een betaald bericht reist zonder tekst', () => {
+  const e = postEntry(post({ paid: 1 }), { user: VREEMDE, site: SITE }, { renderBody });
+  assert.equal(e.access, 'paid');
+  assert.equal(e.content_html, null, 'het lijf wordt niet eens gerenderd');
+  assert.match(e.teaser, /eerste alinea/);
+  assert.ok(!JSON.stringify(e).includes('tweede alinea'), 'en niets erachter lekt mee');
+});
+
+test('een fan-bericht ook niet', () => {
+  const e = postEntry(post({ fan_only: 1 }), { user: VREEMDE, site: SITE }, { renderBody });
+  assert.equal(e.access, 'fan');
+  assert.equal(e.content_html, null);
+});
+
+test('een concept levert helemaal niets op', () => {
+  const e = postEntry(post({ status: 'draft' }), { user: VREEMDE, site: SITE }, { renderBody });
+  assert.equal(e.access, 'forbidden');
+  assert.equal(e.content_html, null);
+  assert.equal(e.teaser, null, 'ook geen teaser: over een concept valt niets te zeggen');
+});
+
+test('zonder renderer nog steeds geen lijf, en geen fout', () => {
+  const e = postEntry(post(), { user: VREEMDE, site: SITE });
+  assert.equal(e.access, 'full');
+  assert.equal(e.content_html, null);
+});
