source: Klonkt/test/paid-federation.test.js

main
Last change on this file was 928d1c7, checked in by Robin <roboburr@…>, 7 weeks ago

Feature: paid posts slice 2, post model + teaser gate

A post can be marked paid (klonkt-demo-aki), premium-gated in the
editor with an optional per-post price; additive columns posts.paid +
paid_min_cents. On the web, a paid post shows only a teaser to anyone
who is not the owner/editor (new pages/paid-gate, mirroring the
fan-gate); the owner previews the full post. The passkey unlock arrives
in slices 3-4, so the gate says so for now.

Federation is leak-safe: buildNote federates only a PUBLIC teaser (the
excerpt, else the first paragraph, never later content) plus a
"read the full post (supporters)" link back, and no media attachments.
A short paid post can no longer spill its body: the teaser is the first
paragraph only, pinned by a test.

Changed files:
src/config/database.js

  • additive columns posts.paid, posts.paid_min_cents

src/routes/posts.js

  • create/update read paid + price (premium-gated), store them, pass to deliverCreate/Update; paidTeaser helper; the paid web gate

src/services/ActivityPubService.js

  • buildNote: paid post -> public teaser + link, first paragraph only

src/views/pages/post-edit.ejs

  • paid toggle + price field (in the premium block)

New file:
src/views/pages/paid-gate.ejs

  • teaser + supporters notice

test/paid-federation.test.js

  • teaser + link, no full content, excerpt-as-teaser, non-paid intact

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

  • Property mode set to 100644
File size: 2.2 KB
Line 
1// Paid posts slice 2 (klonkt-demo-aki): a paid post federates a PUBLIC teaser +
2// link, never its full content, so nothing leaks past the paywall.
3import { test } from 'node:test';
4import assert from 'node:assert/strict';
5
6process.env.DATABASE_PATH = ':memory:';
7process.env.PUBLIC_BASE_URL = 'https://test.example';
8
9const dbMod = await import('../src/config/database.js');
10dbMod.initializeDatabase();
11const AP = (await import('../src/services/ActivityPubService.js')).default;
12
13const base = 'https://test.example';
14const site = { slug: 'me', primary_slug: 'me' };
15const SECRET = 'THE-SECRET-BODY-that-must-not-federate';
16
17test('a paid post federates a teaser + supporters link, not the full content', () => {
18 const post = {
19 id: 'p1', slug: 'geheim', title: 'Geheim', tags: '[]',
20 content: `<p>intro zin die de teaser wordt.</p><p>${SECRET}</p>`,
21 excerpt: '', paid: 1, created_at: '2026-01-01T00:00:00Z', published_at: '2026-01-01T00:00:00Z',
22 };
23 const note = AP.buildNote(base, site, post);
24 assert.equal(note.type, 'Note');
25 assert.ok(note.content.includes('Lees de volledige post'), 'has the supporters link');
26 assert.ok(note.content.includes(`${base}/geheim`), 'links back to the post');
27 assert.ok(!note.content.includes(SECRET), 'the secret body must NOT federate');
28 assert.deepEqual(note.to, ['https://www.w3.org/ns/activitystreams#Public'], 'teaser is public');
29 assert.ok(!('attachment' in note) || !note.attachment || note.attachment.length === 0, 'no media leaks');
30});
31
32test('the excerpt is used as the teaser when present', () => {
33 const post = {
34 id: 'p2', slug: 'x', title: 'X', tags: '[]',
35 content: `<p>${SECRET}</p>`, excerpt: 'Netjes teasertje', paid: 1,
36 created_at: '2026-01-01T00:00:00Z',
37 };
38 const note = AP.buildNote(base, site, post);
39 assert.ok(note.content.includes('Netjes teasertje'));
40 assert.ok(!note.content.includes(SECRET));
41});
42
43test('a normal (non-paid) post still federates its full content', () => {
44 const post = {
45 id: 'p3', slug: 'open', title: 'Open', tags: '[]',
46 content: `<p>${SECRET}</p>`, paid: 0, created_at: '2026-01-01T00:00:00Z',
47 };
48 const note = AP.buildNote(base, site, post);
49 assert.ok(note.content.includes(SECRET), 'non-paid content federates as before');
50});
Note: See TracBrowser for help on using the repository browser.