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

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

Feature: paid posts slice 4, cookie-less per-post unlock

The unlock leg of the paid-posts flow (klonkt-demo-3lz). A supporter who
already made a passkey (slice 3) opens a paid post and unlocks it with a
WebAuthn assertion, no account and no cookie.

  • Cookie-less: GET /paid/challenge hands out authentication options plus a short-lived (300s) signed blob carrying the challenge, the post slug and the post's required cents. The client returns both to POST /paid/unlock; nothing is kept between the two requests.
  • Discoverable credentials: allowCredentials is empty, so the browser offers the site's passkeys and the visitor stays pseudonymous.
  • Gate checks, in order: valid+unexpired entitlement for this passkey and site (else 403 -> the page sends the visitor to /paid/link to register), tier (entitlement cents >= post cents, else 403), then the assertion is verified and the signature counter bumped (clone detection).
  • The full post body is returned in that SAME response (renderPostBodyHtml, extracted from the page pipeline so unlocked HTML matches the normal render exactly). No unlock token becomes state.

Note: injected content covers text, images and external embeds; the
own-hosted audio player binds on load and is not re-initialised in
injected HTML yet (follow-up).

Changed files:
src/routes/posts.js

  • export renderPostBodyHtml (shared by the page and the unlock route)

src/services/PasskeyService.js

  • authenticationOptions, verifyAssertion, bumpCounter

src/routes/paid.js

  • GET /paid/challenge, POST /paid/unlock (cookie-less)

src/views/pages/paid-gate.ejs

  • Ontgrendel button + vendored SimpleWebAuthnBrowser assertion script; swaps the gate for the post on success, links to Patreon on 403

test/paid-unlock.test.js

  • auth options challenge + empty allowCredentials, counter bump, tier gate, expired entitlement not served

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

  • Property mode set to 100644
File size: 2.4 KB
Line 
1// Paid posts slice 4 (klonkt-demo-3lz): the per-post unlock. The WebAuthn
2// assertion needs a browser, so here we cover the pure pieces: authentication
3// options carry a challenge and empty allowCredentials (discoverable), the
4// counter bumps, and the tier gate compares entitlement cents to the post's.
5import { test } from 'node:test';
6import assert from 'node:assert/strict';
7
8process.env.DATABASE_PATH = ':memory:';
9process.env.PUBLIC_BASE_URL = 'https://test.example';
10process.env.PAID_SECRET = 'a-test-paid-secret-of-sufficient-length';
11
12const dbMod = await import('../src/config/database.js');
13const db = dbMod.default;
14dbMod.initializeDatabase();
15const Passkey = (await import('../src/services/PasskeyService.js')).default;
16
17test('authentication options carry a challenge, rpID host, and empty allowCredentials', async () => {
18 const opts = await Passkey.authenticationOptions('https://test.example');
19 assert.ok(opts.challenge && typeof opts.challenge === 'string');
20 assert.equal(opts.rpId, 'test.example');
21 assert.deepEqual(opts.allowCredentials || [], []); // discoverable: browser offers the passkeys
22});
23
24test('bumpCounter persists the new signature counter (clone detection)', () => {
25 Passkey.storeEntitlement({ credentialId: 'uc1', siteId: 's1', publicKey: 'PK', counter: 4, minCents: 300 });
26 Passkey.bumpCounter('uc1', 7);
27 const row = db.prepare('SELECT counter FROM paid_entitlements WHERE credential_id = ?').get('uc1');
28 assert.equal(row.counter, 7);
29});
30
31test('tier gate: an entitlement below the post cents is refused, at/above passes', () => {
32 Passkey.storeEntitlement({ credentialId: 'uc2', siteId: 's1', publicKey: 'PK', counter: 0, minCents: 300 });
33 const ent = Passkey.getEntitlement('uc2', 's1');
34 // mirrors the /paid/unlock check: (ent.min_cents || 0) < payload.cents -> refuse
35 assert.equal((ent.min_cents || 0) < 500, true); // post needs 500, entitlement 300 -> blocked
36 assert.equal((ent.min_cents || 0) < 300, false); // post needs 300 -> allowed
37 assert.equal((ent.min_cents || 0) < 100, false); // post needs 100 -> allowed
38});
39
40test('an expired entitlement is not returned to the unlock path', () => {
41 Passkey.storeEntitlement({ credentialId: 'uc3', siteId: 's1', publicKey: 'PK', minCents: 100, ttlDays: 30 });
42 db.prepare('UPDATE paid_entitlements SET expires_at = 1 WHERE credential_id = ?').run('uc3');
43 assert.equal(Passkey.getEntitlement('uc3', 's1'), null);
44});
Note: See TracBrowser for help on using the repository browser.