Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision fbacddc02f08c6a7ed8e09f98fdd30b318feb63c)
+++ src/routes/posts.js	(revision b4af6f3a028053e65a6bdb83c3202bd7efa3e641)
@@ -822,4 +822,31 @@
 }
 
+// De muziek van een betaalde post op de poortpagina zelf.
+//
+// WAAROM DIE DAAR HOORT. Zodra een nummer `fedi_open` is, federeert het als
+// eigen Audio-object en speelt het bij iedereen die de post in een hub of in
+// Mastodon tegenkomt. Toonde de poort het dan NIET, dan was de muziek overal
+// beschikbaar behalve op de site die hem uitbrengt -- en dat is de verkeerde
+// kant op (Robin, 24-8). De muur staat om de tekst.
+//
+// ALLES OF NIETS. Alleen als ELK nummer waar de post naar wijst open staat.
+// Een shortcode rendert zijn hele lijst, dus bij een half-open bandje zou de
+// speler ook de gesloten nummers krijgen -- en /audio/stream laat een
+// gelijke-oorsprong-fetch door, dus dat is geen theoretisch lek maar een echt.
+// Half open is hier dus dicht.
+//
+// De TEKST komt hier niet langs: we geven renderPostBodyHtml een post mee die
+// alleen uit de audio-shortcodes bestaat. Wat niet meegegeven wordt kan ook
+// niet lekken -- dezelfde regel als bij readerItems.
+export function paidOpenAudioHtml(site, post, req) {
+  if (!postAudioFediOpen(site.id, post.content)) return '';
+  const codes = String(post.content || '').match(/\[\[(?:track|album|playlist):[^\]]+\]\]/gi) || [];
+  if (!codes.length) return '';
+  const alleenMuziek = codes.join('\n');
+  try {
+    return renderPostBodyHtml(site, { ...post, content: alleenMuziek, content_rendered: alleenMuziek }, req);
+  } catch { return ''; /* geen speler is geen kapotte poort */ }
+}
+
 function postNeighbors(site, post) {
   const ordered = db.prepare(`
@@ -1573,10 +1600,12 @@
   if (post.paid && !canEditThis && !_unlocked) {
     const { newerPost, olderPost } = postNeighbors(site, post);
+    const pgAudio = paidOpenAudioHtml(site, post, req);
     return renderPage(req, res, 'pages/paid-gate', {
-    pageJs: 'paid-gate',
+    pageJs: 'paid-gate' + (pgAudio ? ' tape' : ''),
       pageTitle: post.title || 'Voor supporters',
       bodyClass: 'on-special',
       pgTitle: post.title || '',
       pgTeaser: paidTeaser(post),
+      pgAudio,
       pgCents: post.paid_min_cents || paidDefaultMinCents(site.id),
       pgSlug: post.slug,
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision fbacddc02f08c6a7ed8e09f98fdd30b318feb63c)
+++ src/services/ActivityPubService.js	(revision b4af6f3a028053e65a6bdb83c3202bd7efa3e641)
@@ -496,4 +496,59 @@
 }
 
+// fedi_open tracks → real AS2 Audio attachments (the actual file URL, served ungated) so
+// EVERY client incl. the Mastodon apps plays them inline natively. Gated tracks (default)
+// stay link/card-only — the file is never exposed for them. Resolve from post.content so a
+// later body mutation can't affect it.
+//
+// Staat apart en niet meer midden in buildNote, omdat een BETAALDE post hem ook
+// nodig heeft: daar staat de muur om de TEKST en niet om de muziek.
+function openAudioAttachments(base, site, post) {
+  const openAudio = [];
+  if (!/\[\[(track|album|playlist):/i.test(post.content || '')) return openAudio;
+  const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
+  const seenA = new Set();
+  const addRow = (r) => {
+    const fn = r.filename || (r.storage_path || '').split('/').pop();
+    if (!fn || seenA.has(fn)) return; seenA.add(fn);
+    const a = { type: 'Audio', mediaType: r.mime_type || 'audio/mpeg', url: `${base}/audio/stream/${encodeURIComponent(fn)}`, name: r.title || 'Audio' };
+    // Cover art on the Audio attachment (AS2 `icon`): track cover, else the post cover.
+    // Mastodon renders it as the artwork thumbnail on its native audio player.
+    const art = abs(r.cover_url || post.cover_image_url || null);
+    if (art) a.icon = { type: 'Image', mediaType: guessMediaType(art), url: art };
+    openAudio.push(a);
+  };
+  const SEL = 'SELECT t.title, t.cover_url, m.filename, m.storage_path, m.mime_type FROM audio_tracks t JOIN media m ON m.id = t.media_id WHERE t.fedi_open = 1 AND ';
+  try {
+    for (const mm of (post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) { const r = db.prepare(SEL + 't.id = ?').get(mm[1]); if (r) addRow(r); }
+    for (const mm of (post.content || '').matchAll(/\[\[album:([^\]]+)\]\]/g)) for (const r of db.prepare(SEL + 't.site_id = ? AND t.album = ? ORDER BY t.rowid').all(site.id, mm[1].trim())) addRow(r);
+    for (const mm of (post.content || '').matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) for (const r of db.prepare('SELECT t.title, t.cover_url, m.filename, m.storage_path, m.mime_type FROM playlist_tracks pt JOIN audio_tracks t ON t.id = pt.track_id JOIN media m ON m.id = t.media_id WHERE t.fedi_open = 1 AND pt.playlist_id = ? ORDER BY pt.position').all(mm[1])) addRow(r);
+  } catch { /* non-fatal */ }
+  return openAudio;
+}
+
+// HET BANDJE OP DE DRAAD. Zonder dit stuk bestaat `Mixtape` alleen in onze
+// eigen code: de playlist-collectie blijft namelijk een OrderedCollection
+// (dat moet, anders verliest een lezer die `type` als tekst uitpakt het hele
+// object), en dan zegt niets naar buiten toe ooit dat dit een cassette is.
+// Gemeten op 21-8: in de Note van een mixtape-post kwam het woord Mixtape
+// niet voor, en de hub gooide zo'n bandje daarom stil weg.
+//
+// Als bijlage en niet als het object zelf: de post blijft een Note, zodat
+// Mastodon en alles wat `Mixtape` niet kent gewoon een bericht met audio
+// ziet. Wie het type wel kent, vindt het bandje als geheel.
+//
+// Het bandje draagt alleen wat al open staat: playlistOpenTracks filtert op
+// fedi_open. Daarom is het veilig om hem ook aan een betaalde teaser te hangen.
+function mixtapeAttachment(base, site, post) {
+  try {
+    const soort = postMusicType(post.content || '', site.id);
+    if (!soort || soort.type !== 'mixtape' || !soort.collectie || !soort.collectie.id) return null;
+    const pl = db.prepare('SELECT * FROM playlists WHERE id = ? AND site_id = ?')
+      .get(soort.collectie.id, site.id);
+    if (!pl) return null;
+    return buildMixtapeObject(base, site, { ...pl, _post: post }, playlistOpenTracks(pl.id)) || null;
+  } catch { return null; /* een bandje minder is geen kapotte post */ }
+}
+
 // A single post as an AS2 Note (the object), and as a Create activity (for outbox/delivery).
 export function buildNote(base, site, post, opts = {}) {
@@ -565,5 +620,15 @@
 
   // Paid post (klonkt-demo-aki): federate a PUBLIC teaser + link, never the full
-  // content, so nothing leaks past the paywall. No media attachments either.
+  // content, so nothing leaks past the paywall. Images stay home too.
+  //
+  // MAAR DE OPENGEZETTE AUDIO REIST WEL MEE (Robin, 24-8, naar aanleiding van
+  // boiert.eu/introducing-this-machine). De muur staat om de TEKST. `fedi_open`
+  // is een aparte, eenrichtings, per nummer bewust gezette vlag van de eigenaar,
+  // en die nummers federeren toch al los als eigen Audio-objecten met hun
+  // `context` naar deze post. Hield deze tak het bandje tegen, dan hield hij
+  // niets geheim -- alleen de VOLGORDE en het feit dat het een cassette is. In
+  // de hub viel het bandje daardoor uiteen in vier losse nummers onder een kale
+  // teaserkaart. Een cassette die terugwijst naar "lees verder (supporters)"
+  // dient de betaalde post beter dan vier weesnummers.
   if (post.paid) {
     const esc = (x) => String(x || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
@@ -571,4 +636,9 @@
     const rawTeaser = String(post.excerpt || '').trim()
       || _firstP.replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim().slice(0, 280);
+    const openBijlagen = openAudioAttachments(base, site, post);
+    const band = mixtapeAttachment(base, site, post);
+    // Het bandje alleen als er ook echt iets open in zit: een cassette waarvan
+    // elk nummer gesloten is, is een lege doos met een titel erop.
+    if (band && openBijlagen.length) openBijlagen.push(band);
     return {
       '@context': AP_CONTEXT,
@@ -579,4 +649,5 @@
       url: human,
       published: toISO(post.published_at || post.created_at || Date.now()),
+      ...(openBijlagen.length ? { attachment: openBijlagen } : {}),
       to: [PUBLIC],
       cc: [`${aId}/followers`],
@@ -705,28 +776,5 @@
     }
   } catch { /* non-fatal */ }
-  // fedi_open tracks → real AS2 Audio attachments (the actual file URL, served ungated) so
-  // EVERY client incl. the Mastodon apps plays them inline natively. Gated tracks (default)
-  // stay link/card-only — the file is never exposed for them. Resolve from post.content so a
-  // later body mutation can't affect it.
-  const openAudio = [];
-  if (hadAudio) {
-    const seenA = new Set();
-    const addRow = (r) => {
-      const fn = r.filename || (r.storage_path || '').split('/').pop();
-      if (!fn || seenA.has(fn)) return; seenA.add(fn);
-      const a = { type: 'Audio', mediaType: r.mime_type || 'audio/mpeg', url: `${base}/audio/stream/${encodeURIComponent(fn)}`, name: r.title || 'Audio' };
-      // Cover art on the Audio attachment (AS2 `icon`): track cover, else the post cover.
-      // Mastodon renders it as the artwork thumbnail on its native audio player.
-      const art = abs(r.cover_url || post.cover_image_url || null);
-      if (art) a.icon = { type: 'Image', mediaType: guessMediaType(art), url: art };
-      openAudio.push(a);
-    };
-    const SEL = 'SELECT t.title, t.cover_url, m.filename, m.storage_path, m.mime_type FROM audio_tracks t JOIN media m ON m.id = t.media_id WHERE t.fedi_open = 1 AND ';
-    try {
-      for (const mm of (post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) { const r = db.prepare(SEL + 't.id = ?').get(mm[1]); if (r) addRow(r); }
-      for (const mm of (post.content || '').matchAll(/\[\[album:([^\]]+)\]\]/g)) for (const r of db.prepare(SEL + 't.site_id = ? AND t.album = ? ORDER BY t.rowid').all(site.id, mm[1].trim())) addRow(r);
-      for (const mm of (post.content || '').matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) for (const r of db.prepare('SELECT t.title, t.cover_url, m.filename, m.storage_path, m.mime_type FROM playlist_tracks pt JOIN audio_tracks t ON t.id = pt.track_id JOIN media m ON m.id = t.media_id WHERE t.fedi_open = 1 AND pt.playlist_id = ? ORDER BY pt.position').all(mm[1])) addRow(r);
-    } catch { /* non-fatal */ }
-  }
+  const openAudio = openAudioAttachments(base, site, post);
   // ONVERTAALD voor een verhuizing (FEP-1580). De doelinstantie IS een Klonkt:
   // die rendert [[track:]], [[album:]] en [[playlist:]] zelf en maakt er een
@@ -795,25 +843,7 @@
   for (const a of openAudio) attachment.push(a); // fedi_open tracks → native Audio players
 
-  // HET BANDJE OP DE DRAAD. Zonder dit stuk bestaat `Mixtape` alleen in onze
-  // eigen code: de playlist-collectie blijft namelijk een OrderedCollection
-  // (dat moet, anders verliest een lezer die `type` als tekst uitpakt het hele
-  // object), en dan zegt niets naar buiten toe ooit dat dit een cassette is.
-  // Gemeten op 21-8: in de Note van een mixtape-post kwam het woord Mixtape
-  // niet voor, en de hub gooide zo'n bandje daarom stil weg.
-  //
-  // Als bijlage en niet als het object zelf: de post blijft een Note, zodat
-  // Mastodon en alles wat `Mixtape` niet kent gewoon een bericht met audio
-  // ziet. Wie het type wel kent, vindt het bandje als geheel.
-  try {
-    const soort = postMusicType(post.content || '', site.id);
-    if (soort && soort.type === 'mixtape' && soort.collectie && soort.collectie.id) {
-      const pl = db.prepare('SELECT * FROM playlists WHERE id = ? AND site_id = ?')
-        .get(soort.collectie.id, site.id);
-      if (pl) {
-        const tape = buildMixtapeObject(base, site, { ...pl, _post: post }, playlistOpenTracks(pl.id));
-        if (tape) attachment.push(tape);
-      }
-    }
-  } catch { /* een bandje minder is geen kapotte post */ }
+  // Het bandje als bijlage — zie mixtapeAttachment() voor het waarom.
+  const tape = mixtapeAttachment(base, site, post);
+  if (tape) attachment.push(tape);
 
   // Inline @user@host mentions: the Mention tag objects + the mentioned actor URIs. Only
Index: src/views/pages/paid-gate.ejs
===================================================================
--- src/views/pages/paid-gate.ejs	(revision fbacddc02f08c6a7ed8e09f98fdd30b318feb63c)
+++ src/views/pages/paid-gate.ejs	(revision b4af6f3a028053e65a6bdb83c3202bd7efa3e641)
@@ -8,4 +8,12 @@
   <% if (typeof pgTeaser !== 'undefined' && pgTeaser) { %>
     <div class="pg-teaser"><p><%= pgTeaser %></p></div>
+  <% } %>
+
+  <%# De opengezette muziek staat VOOR de poort en niet erachter: wie hem in een
+      hub of in Mastodon al kan afspelen, hoort hem ook hier te kunnen afspelen.
+      Alleen de tekst zit achter de poort, en die staat hier niet -- de route
+      geeft alleen de audio-shortcodes mee (paidOpenAudioHtml). %>
+  <% if (typeof pgAudio !== 'undefined' && pgAudio) { %>
+    <div class="pg-audio post-content"><%- pgAudio %></div>
   <% } %>
 
@@ -39,4 +47,5 @@
   .pg-teaser { font-family: var(--font-body, serif); font-size: 1.1rem; line-height: 1.7; color: var(--ink); opacity: .95;
     -webkit-mask-image: linear-gradient(180deg, #000 55%, transparent); mask-image: linear-gradient(180deg, #000 55%, transparent); }
+  .pg-audio { margin: 1.5rem 0 0; }
   .pg-card { margin: 1.5rem 0 0; border: 1px solid color-mix(in srgb, var(--ink, #000) 16%, transparent); border-radius: 18px; padding: 30px 26px; text-align: center; }
   .pg-lock { font-size: 38px; margin-bottom: 6px; }
Index: test/paid-open-audio.test.js
===================================================================
--- test/paid-open-audio.test.js	(revision b4af6f3a028053e65a6bdb83c3202bd7efa3e641)
+++ test/paid-open-audio.test.js	(revision b4af6f3a028053e65a6bdb83c3202bd7efa3e641)
@@ -0,0 +1,135 @@
+// Opengezette audio onder een BETAALDE post (Robin, 24-8).
+//
+// Aanleiding: boiert.eu/introducing-this-machine — een mixtape, betaald, met
+// `fedi_open` audio. De vroege return in buildNote gooide ALLE bijlagen weg,
+// dus ook het Mixtape-object. De nummers federeerden ondertussen gewoon los als
+// eigen Audio-objecten met hun `context` naar die post, en in de hub viel het
+// bandje daardoor uiteen in vier losse nummers onder een kale teaserkaart.
+//
+// Wat de poort tegenhield was dus niet inhoud maar VORM: de volgorde en het
+// feit dat het een cassette is. Deze tests leggen de nieuwe grens vast:
+//   - de tekst blijft achter de muur, altijd;
+//   - `fedi_open` audio gaat mee, met het bandje eromheen;
+//   - audio die NIET open staat gaat niet mee, ook niet als losse bijlage;
+//   - en wat federeert is op de site zelf ook te horen (de poortpagina).
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://ons.test';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = await import('../src/services/ActivityPubService.js');
+const { paidOpenAudioHtml } = await import('../src/routes/posts.js');
+
+const BASE = 'https://ons.test';
+const GEHEIM = 'DIT IS DE BETAALDE TEKST DIE NERGENS MAG STAAN';
+
+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) VALUES (?,?,?,?)')
+  .run('s1', 'band', 'De Band', 'u1');
+const site = db.prepare("SELECT * FROM sites WHERE id = 's1'").get();
+
+// Twee open nummers in een bandje...
+const media = db.prepare('INSERT INTO media (id, site_id, filename, storage_path, mime_type, size) VALUES (?,?,?,?,?,?)');
+const track = db.prepare('INSERT INTO audio_tracks (id, site_id, title, artist, duration, media_id, fedi_open) VALUES (?,?,?,?,?,?,?)');
+media.run('m1', 's1', 'een.mp3', 'a/een.mp3', 'audio/mpeg', 100);
+media.run('m2', 's1', 'twee.mp3', 'a/twee.mp3', 'audio/mpeg', 100);
+track.run('t1', 's1', 'Kant A', 'De Band', 60, 'm1', 1);
+track.run('t2', 's1', 'Kant B', 'De Band', 60, 'm2', 1);
+db.prepare("INSERT INTO playlists (id, site_id, title, kind) VALUES ('bandje','s1','Het Bandje','mixtape')").run();
+db.prepare("INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES ('bandje','t1',1)").run();
+db.prepare("INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES ('bandje','t2',2)").run();
+
+// ...en eentje dat dicht blijft.
+media.run('m9', 's1', 'dicht.mp3', 'a/dicht.mp3', 'audio/mpeg', 100);
+track.run('t9', 's1', 'Gesloten', 'De Band', 60, 'm9', 0);
+
+function maak({ id, slug, muziek, paid = 1 }) {
+  db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, excerpt, content,
+              status, published_at, paid) VALUES (?,?,?,?,?,?,?,'published',?,?)`)
+    .run(id, 's1', 'u1', slug, 'Titel', 'Een teaser.',
+      `<p>${GEHEIM}</p>${muziek}`, '2026-08-24T12:00:00Z', paid);
+  return db.prepare('SELECT * FROM posts WHERE id = ?').get(id);
+}
+
+const alleTekst = (o) => JSON.stringify(o);
+const bijlagen = (n) => (Array.isArray(n.attachment) ? n.attachment : (n.attachment ? [n.attachment] : []));
+
+test('een betaald bandje federeert zijn open nummers EN de cassette', () => {
+  const note = AP.buildNote(BASE, site, maak({ id: 'p1', slug: 'bandje-betaald', muziek: '<p>[[playlist:bandje]]</p>' }));
+  const bij = bijlagen(note);
+
+  const audio = bij.filter((a) => a.type === 'Audio');
+  assert.equal(audio.length, 2, 'beide open nummers horen mee te reizen');
+  assert.deepEqual(audio.map((a) => a.name), ['Kant A', 'Kant B']);
+  assert.ok(audio.every((a) => a.url.startsWith(`${BASE}/audio/stream/`)), 'het bestand zelf, niet een link naar de poort');
+
+  const tape = bij.find((a) => a.type === 'Mixtape');
+  assert.ok(tape, 'zonder dit object valt het bandje in de hub uiteen in losse nummers');
+  assert.equal(tape.totalItems, 2);
+  assert.equal(tape.orderedItems.length, 2, 'de VOLGORDE is het werk; die hoort erin te staan');
+});
+
+test('en de tekst blijft ondertussen gewoon achter de muur', () => {
+  const note = AP.buildNote(BASE, site, maak({ id: 'p2', slug: 'bandje-betaald-2', muziek: '<p>[[playlist:bandje]]</p>' }));
+  assert.ok(!alleTekst(note).includes(GEHEIM), 'de betaalde inhoud staat nergens in het object');
+  assert.match(note.content, /Een teaser/);
+  assert.match(note.content, /Lees de volledige post/);
+});
+
+test('audio die NIET is opengezet reist niet mee', () => {
+  const note = AP.buildNote(BASE, site, maak({ id: 'p3', slug: 'dicht', muziek: '<p>[[track:t9]]</p>' }));
+  assert.equal(bijlagen(note).length, 0, 'een gesloten nummer hoort de deur niet uit te komen');
+});
+
+test('een betaalde post zonder muziek verandert niet: geen bijlagen', () => {
+  const note = AP.buildNote(BASE, site, maak({ id: 'p4', slug: 'kaal', muziek: '' }));
+  assert.equal(bijlagen(note).length, 0);
+});
+
+test('een gewone (niet-betaalde) post houdt dezelfde bijlagen', () => {
+  // De helper wordt door BEIDE takken gebruikt; deze test bewaakt dat het
+  // uitlichten ervan de gewone tak niet stilletjes heeft leeggehaald.
+  const note = AP.buildNote(BASE, site, maak({ id: 'p5', slug: 'open-bandje', muziek: '<p>[[playlist:bandje]]</p>', paid: 0 }));
+  const bij = bijlagen(note);
+  assert.equal(bij.filter((a) => a.type === 'Audio').length, 2);
+  assert.ok(bij.some((a) => a.type === 'Mixtape'), 'het bandje hangt er nog steeds aan');
+  assert.ok(alleTekst(note).includes(GEHEIM), 'en de inhoud van een OPEN post federeert gewoon');
+});
+
+// ── de poortpagina op de site zelf ───────────────────────────────────────────
+// Wat de hub kan afspelen hoort de site zelf ook te kunnen afspelen. Andersom
+// stond de muziek overal behalve op de plek die hem uitbrengt.
+
+test('de poort toont de speler van een open bandje', () => {
+  const post = maak({ id: 'p6', slug: 'poort', muziek: '<p>[[playlist:bandje]]</p>' });
+  const html = paidOpenAudioHtml(site, post, { session: null });
+  assert.ok(html, 'de poort hoort hier een speler te krijgen');
+  assert.ok(html.includes('Kant A') && html.includes('Kant B'), 'met de nummers erin');
+  assert.ok(!html.includes(GEHEIM), 'maar NOOIT met de betaalde tekst');
+  assert.ok(!html.includes('Een teaser'), 'en ook niet met de rest van de post');
+});
+
+test('de poort toont niets als de audio dicht staat', () => {
+  const post = maak({ id: 'p7', slug: 'poort-dicht', muziek: '<p>[[track:t9]]</p>' });
+  assert.equal(paidOpenAudioHtml(site, post, { session: null }), '');
+});
+
+test('half open is dicht: een gesloten nummer sluit de hele speler', () => {
+  // Een shortcode rendert zijn hele lijst. Bij een half-open bandje zou de
+  // speler ook het gesloten bestand krijgen, en /audio/stream laat een
+  // gelijke-oorsprong-fetch door -- dat is een echt lek, geen theoretisch.
+  const post = maak({ id: 'p8', slug: 'half', muziek: '<p>[[playlist:bandje]]</p><p>[[track:t9]]</p>' });
+  assert.equal(paidOpenAudioHtml(site, post, { session: null }), '');
+});
+
+test('de poortpagina print de speler ook echt', async () => {
+  const fs = await import('node:fs');
+  const bron = fs.readFileSync('src/views/pages/paid-gate.ejs', 'utf8');
+  assert.match(bron, /pgAudio/, 'zonder dit blijft de speler in de route steken');
+  assert.match(bron, /<%- pgAudio %>/, 'ontsnapt hij, dan staat er HTML als tekst op het scherm');
+});
