source: Klonkt/test/paid-patron.test.js@ e685f55

main
Last change on this file since e685f55 was 9e9e6f9, checked in by Robin <roboburr@…>, 7 weeks ago

Feature: paid posts slice 3, patron link + passkey (cookie-less)

The registration leg of the paid-posts flow (klonkt-demo-aki). A
visitor links once via Patreon and gets a pseudonymous passkey entitlement,
with no session and no patron identity stored.

  • Dependency (approved): @simplewebauthn/server for verification, plus @simplewebauthn/browser vendored (UMD) so the page loads it with no CDN.
  • New paid_entitlements table: {passkey, site, proven cents, expiry}. No name, e-mail or Patreon id, ever.
  • Cookie-less throughout: the OAuth state and the WebAuthn challenge travel in signed blobs (CryptoBox), so nothing is kept between requests.
  • Flow: GET /paid/link -> Patreon authorize; GET /paid/callback verifies the patron (verifyPatron exchanges the code, reads identity?include=memberships.campaign, checks patron_status + currently_entitled_amount_cents against the post's price), then hands out registration options + a signed blob carrying the challenge and proven cents; POST /paid/register verifies the passkey and stores the entitlement. The patron token is used once and discarded.

The gate button and the per-post unlock (assertion) are slice 4; this
leg is what that flow calls to register a passkey on demand.

Changed files:
package.json, package-lock.json

  • @simplewebauthn/server + @simplewebauthn/browser

src/assets/vendor/simplewebauthn-browser.umd.min.js

  • vendored browser UMD (no CDN)

src/config/database.js

  • paid_entitlements table (no patron identity)

src/services/PaidPatreonService.js

  • pickCampaignMembership (pure), verifyPatron (exchange + identity)

src/server.js

  • mount /paid before the /:slug catch-all

New file:
src/services/PasskeyService.js

  • registration options + verify (lib) + entitlement store/prune

src/routes/paid.js

  • link / callback / register (cookie-less)

src/views/pages/paid-passkey.ejs, paid-result.ejs

  • passkey creation + not-a-supporter pages

test/paid-patron.test.js

  • membership parse, patron exchange (mock), options challenge, entitlement store/expiry/prune/delete, no-identity-columns

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

  • Property mode set to 100644
File size: 3.9 KB
RevLine 
[9e9e6f9]1// Paid posts slice 3 (klonkt-demo-aki): patron verification + pseudonymous
2// passkey entitlements. The WebAuthn ceremony itself needs a browser, so here
3// we cover the pure parsing, the Patreon exchange (mock fetch), the entitlement
4// store, and that registration options carry a challenge.
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 PP = (await import('../src/services/PaidPatreonService.js')).default;
16const Passkey = (await import('../src/services/PasskeyService.js')).default;
17
18PP.saveOwnerConfig('s1', { clientId: 'cid', clientSecret: 'sec', campaignId: '42', defaultMinCents: 300 });
19
20const identityWith = (campaignId, status, cents) => ({
21 data: { type: 'user', id: 'u', relationships: { memberships: { data: [{ type: 'member', id: 'm1' }] } } },
22 included: [
23 { type: 'member', id: 'm1', attributes: { patron_status: status, currently_entitled_amount_cents: cents },
24 relationships: { campaign: { data: { type: 'campaign', id: campaignId } } } },
25 { type: 'campaign', id: campaignId },
26 ],
27});
28
29test('pickCampaignMembership finds the right campaign, ignores others', async () => {
30 const { pickCampaignMembership } = await import('../src/services/PaidPatreonService.js');
31 const id = identityWith('42', 'active_patron', 500);
32 const m = pickCampaignMembership(id, '42');
33 assert.equal(m.status, 'active_patron');
34 assert.equal(m.cents, 500);
35 assert.equal(pickCampaignMembership(id, '999'), null); // different campaign
36});
37
38test('verifyPatron exchanges the code and reads the membership (mock fetch)', async () => {
39 const calls = [];
40 const fetchMock = async (url, opts) => {
41 calls.push(url);
42 if (url.includes('/token')) return { ok: true, json: async () => ({ access_token: 'patron-tok' }) };
43 return { ok: true, json: async () => identityWith('42', 'active_patron', 800) };
44 };
45 const m = await PP.verifyPatron('s1', 'the-code', 'https://test.example/paid/callback', fetchMock);
46 assert.equal(m.status, 'active_patron');
47 assert.equal(m.cents, 800);
48 assert.ok(calls[0].includes('patreon.com'));
49 assert.ok(calls[1].includes('identity'));
50});
51
52test('registration options carry a challenge and the site host as rpID', async () => {
53 const opts = await Passkey.registrationOptions('https://test.example', 's1');
54 assert.ok(opts.challenge && typeof opts.challenge === 'string');
55 assert.equal(opts.rp.id, 'test.example');
56 assert.equal(opts.authenticatorSelection.residentKey, 'required');
57});
58
59test('entitlement stores, reads, expires, prunes; no patron identity present', () => {
60 Passkey.storeEntitlement({ credentialId: 'cred1', siteId: 's1', publicKey: 'PUBKEY', counter: 0, minCents: 500, ttlDays: 30 });
61 const e = Passkey.getEntitlement('cred1', 's1');
62 assert.ok(e);
63 assert.equal(e.min_cents, 500);
64 // the row has no name/email/patreon id
65 const cols = Object.keys(e);
66 assert.ok(!cols.some((c) => /name|email|patron|user/i.test(c)), 'no identity columns');
67 // expired entitlement is not returned and gets pruned
68 Passkey.storeEntitlement({ credentialId: 'cred2', siteId: 's1', publicKey: 'PK', minCents: 100, ttlDays: 30 });
69 db.prepare('UPDATE paid_entitlements SET expires_at = 1 WHERE credential_id = ?').run('cred2');
70 assert.equal(Passkey.getEntitlement('cred2', 's1'), null);
71 assert.equal(Passkey.pruneExpired() >= 1, true);
72 assert.ok(Passkey.getEntitlement('cred1', 's1')); // the fresh one survives
73});
74
75test('deleteEntitlement removes the row (forget-passkey path)', () => {
76 Passkey.storeEntitlement({ credentialId: 'cred3', siteId: 's1', publicKey: 'PK', minCents: 100 });
77 assert.equal(Passkey.deleteEntitlement('cred3'), true);
78 assert.equal(Passkey.getEntitlement('cred3', 's1'), null);
79});
Note: See TracBrowser for help on using the repository browser.