source: Klonkt/src/views/pages/paid-passkey.ejs@ 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: 2.8 KB
Line 
1<section class="pk">
2 <div class="pk-card">
3 <div class="pk-ic">🔑</div>
4 <h1 class="pk-h1">Je bent supporter, mooi.</h1>
5 <p class="pk-sub">
6 Maak nu een passkey aan. Die wordt je sleutel voor betaalde posts, zonder
7 account en zonder cookie. We bewaren geen naam of e-mailadres.
8 </p>
9 <button type="button" id="pk-go" class="pk-btn">Maak passkey</button>
10 <p id="pk-status" class="pk-status" hidden></p>
11 </div>
12</section>
13
14<script src="/assets/vendor/simplewebauthn-browser.umd.min.js" nonce="<%= cspNonce %>"></script>
15<script nonce="<%= cspNonce %>">
16(function () {
17 var options = <%- optionsJson %>;
18 var blob = "<%= regBlob %>";
19 var postUrl = "<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : '') %>/<%= postSlug %>";
20 var btn = document.getElementById('pk-go');
21 var status = document.getElementById('pk-status');
22 function say(msg, err) { status.hidden = false; status.textContent = msg; status.classList.toggle('is-err', !!err); }
23
24 if (!window.SimpleWebAuthnBrowser || !window.PublicKeyCredential) {
25 btn.disabled = true;
26 say('Passkeys worden niet ondersteund in deze browser.', true);
27 return;
28 }
29 btn.addEventListener('click', function () {
30 btn.disabled = true;
31 say('Volg de vraag van je apparaat…');
32 window.SimpleWebAuthnBrowser.startRegistration({ optionsJSON: options })
33 .then(function (response) {
34 return fetch('/paid/register', {
35 method: 'POST', headers: { 'Content-Type': 'application/json' },
36 body: JSON.stringify({ response: response, blob: blob }),
37 });
38 })
39 .then(function (r) { return r.json(); })
40 .then(function (j) {
41 if (j && j.ok) { say('Gelukt. Je passkey is aangemaakt.'); setTimeout(function () { location.href = postUrl; }, 900); }
42 else { btn.disabled = false; say('Aanmaken mislukt (' + ((j && j.error) || 'onbekend') + '). Probeer opnieuw.', true); }
43 })
44 .catch(function (e) { btn.disabled = false; say(e && e.name === 'NotAllowedError' ? 'Geannuleerd.' : 'Er ging iets mis. Probeer opnieuw.', true); });
45 });
46})();
47</script>
48
49<style>
50 .pk { max-width: 480px; margin: 0 auto; padding: 56px 18px; text-align: center; }
51 .pk-card { border: 1px solid color-mix(in srgb, var(--ink,#000) 16%, transparent); border-radius: 18px; padding: 34px 26px; }
52 .pk-ic { font-size: 40px; margin-bottom: 8px; }
53 .pk-h1 { font-size: 24px; margin: 0 0 6px; }
54 .pk-sub { opacity: .85; line-height: 1.6; margin: 0 0 20px; }
55 .pk-btn { padding: 12px 24px; border: none; border-radius: 10px; background: var(--accent,#6b8f71); color: #fff; font-weight: 600; font: inherit; font-weight: 600; cursor: pointer; }
56 .pk-btn:disabled { opacity: .6; cursor: default; }
57 .pk-status { margin: 14px 0 0; opacity: .9; }
58 .pk-status.is-err { color: #c0392b; }
59</style>
Note: See TracBrowser for help on using the repository browser.