source: Klonkt/src/routes/paid.js@ 9e9e6f9

main
Last change on this file since 9e9e6f9 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: 5.0 KB
Line 
1/**
2 * Paid posts (klonkt-demo-aki) slice 3: the patron link + passkey flow.
3 * Cookie-less throughout: the OAuth state and the WebAuthn challenge travel in
4 * signed blobs (CryptoBox), never a session.
5 *
6 * GET /paid/link?post=<slug> -> redirect to Patreon authorize
7 * GET /paid/callback -> verify patron, render the passkey page
8 * POST /paid/register -> verify the passkey, store the entitlement
9 */
10import express from 'express';
11import db from '../config/database.js';
12import { renderPage } from '../middleware/render.js';
13import { premiumUnlocked } from '../services/PatreonService.js';
14import { signBlob, verifyBlob, cryptoBoxReady } from '../services/CryptoBox.js';
15import PaidPatreon from '../services/PaidPatreonService.js';
16import Passkey from '../services/PasskeyService.js';
17
18const router = express.Router();
19const AUTHORIZE = 'https://www.patreon.com/oauth2/authorize';
20
21const baseUrl = (req) => (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
22
23// The feature is only live when premium is on, secrets can be encrypted, and the
24// owner has connected a campaign.
25function ready(req, res) {
26 const site = res.locals.site;
27 if (!site) { res.status(404).end(); return null; }
28 if (!premiumUnlocked() || !cryptoBoxReady()) { res.status(404).end(); return null; }
29 const cfg = PaidPatreon.getOwnerConfig(site.id);
30 if (!cfg || !cfg.clientId || !cfg.campaignId) { res.status(404).end(); return null; }
31 return { site, cfg };
32}
33
34// Step 1: send the visitor to Patreon.
35router.get('/link', (req, res) => {
36 const r = ready(req, res); if (!r) return;
37 const slug = String(req.query.post || '').trim();
38 const post = slug ? db.prepare('SELECT slug, paid, paid_min_cents FROM posts WHERE site_id = ? AND slug = ?').get(r.site.id, slug) : null;
39 if (!post || !post.paid) return res.redirect((res.locals.siteUrlBase || '') + '/' + (slug || ''));
40 const cents = post.paid_min_cents || PaidPatreon.defaultMinCents(r.site.id);
41 const state = signBlob({ purpose: 'patron', siteId: r.site.id, cents, post: post.slug }, 900);
42 const url = `${AUTHORIZE}?response_type=code&client_id=${encodeURIComponent(r.cfg.clientId)}`
43 + `&redirect_uri=${encodeURIComponent(baseUrl(req) + '/paid/callback')}`
44 + `&scope=${encodeURIComponent('identity identity.memberships')}`
45 + `&state=${encodeURIComponent(state)}`;
46 res.redirect(url);
47});
48
49// Step 2: Patreon returns. Verify the patron; if a supporter at the right tier,
50// render the passkey-creation page.
51router.get('/callback', async (req, res) => {
52 const r = ready(req, res); if (!r) return;
53 const payload = verifyBlob(String(req.query.state || ''));
54 if (!payload || payload.purpose !== 'patron' || payload.siteId !== r.site.id) {
55 return res.status(400).send('Ongeldige of verlopen aanvraag. Probeer opnieuw vanaf de post.');
56 }
57 const code = String(req.query.code || '');
58 if (req.query.error || !code) {
59 return renderPage(req, res, 'pages/paid-result', { pageTitle: 'Ontgrendelen', bodyClass: 'on-special', ok: false, reason: 'declined', postSlug: payload.post });
60 }
61 const membership = await PaidPatreon.verifyPatron(r.site.id, code, baseUrl(req) + '/paid/callback').catch(() => null);
62 const cents = membership ? (membership.cents || 0) : 0;
63 const active = membership && membership.status === 'active_patron';
64 if (!active || cents < payload.cents) {
65 return renderPage(req, res, 'pages/paid-result', {
66 pageTitle: 'Ontgrendelen', bodyClass: 'on-special', ok: false,
67 reason: active ? 'tier' : 'notpatron', neededCents: payload.cents, haveCents: cents, postSlug: payload.post,
68 });
69 }
70 // Supporter at the right tier. Hand out registration options + a signed blob
71 // carrying the challenge and the proven cents; the passkey page returns both.
72 const options = await Passkey.registrationOptions(baseUrl(req), r.site.slug);
73 const blob = signBlob({ purpose: 'reg', siteId: r.site.id, cents, challenge: options.challenge }, 900);
74 renderPage(req, res, 'pages/paid-passkey', {
75 pageTitle: 'Maak je passkey', bodyClass: 'on-special',
76 optionsJson: JSON.stringify(options), regBlob: blob, postSlug: payload.post,
77 });
78});
79
80// Step 3: verify the passkey and store the pseudonymous entitlement.
81router.post('/register', express.json({ limit: '64kb' }), async (req, res) => {
82 const r = ready(req, res); if (!r) return res.status(404).json({ error: 'unavailable' });
83 const { response, blob } = req.body || {};
84 const payload = verifyBlob(String(blob || ''));
85 if (!payload || payload.purpose !== 'reg' || payload.siteId !== r.site.id) {
86 return res.status(400).json({ error: 'bad_challenge' });
87 }
88 const cred = await Passkey.verifyRegistration(baseUrl(req), response, payload.challenge);
89 if (!cred) return res.status(400).json({ error: 'verify_failed' });
90 Passkey.storeEntitlement({
91 credentialId: cred.credentialId, siteId: r.site.id, publicKey: cred.publicKey,
92 counter: cred.counter, transports: cred.transports, minCents: payload.cents,
93 });
94 res.json({ ok: true });
95});
96
97export default router;
Note: See TracBrowser for help on using the repository browser.