Changeset 6cbd014 in Klonkt for src/views/pages


Ignore:
Timestamp:
07/21/2026 01:32:23 AM (7 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
d48ea02
Parents:
d43230f
git-author:
Robin <roboburr@…> (07/21/2026 01:30:40 AM)
git-committer:
Robin <roboburr@…> (07/21/2026 01:32:23 AM)
Message:

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@…>

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/views/pages/paid-gate.ejs

    rd43230f r6cbd014  
    33</div>
    44
    5 <article class="pg-page">
     5<article class="pg-page" id="pg-page">
    66  <% if (typeof pgTitle !== 'undefined' && pgTitle) { %><h1 class="pg-title"><%= pgTitle %></h1><% } %>
    77
     
    1818      dan ontgrendel je 'm met je passkey. Geen account, geen cookie.
    1919    </p>
    20     <p class="pg-soon">Ontgrendelen met je Patreon-passkey komt eraan.</p>
     20    <button type="button" id="pg-unlock" class="pg-btn">Ontgrendelen</button>
     21    <p id="pg-status" class="pg-status" hidden></p>
    2122  </section>
    2223</article>
     24
     25<script src="/assets/vendor/simplewebauthn-browser.umd.min.js" nonce="<%= cspNonce %>"></script>
     26<script nonce="<%= cspNonce %>">
     27(function () {
     28  var base = "<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : '') %>";
     29  var slug = "<%= pgSlug %>";
     30  var btn = document.getElementById('pg-unlock');
     31  var status = document.getElementById('pg-status');
     32  var page = document.getElementById('pg-page');
     33  function say(msg, err) { status.hidden = false; status.textContent = msg; status.classList.toggle('is-err', !!err); }
     34  function toLink() { location.href = base + '/paid/link?post=' + encodeURIComponent(slug); }
     35
     36  if (!window.SimpleWebAuthnBrowser || !window.PublicKeyCredential) { btn.textContent = 'Word supporter'; btn.addEventListener('click', toLink); return; }
     37
     38  btn.addEventListener('click', function () {
     39    btn.disabled = true;
     40    say('Bevestig met je passkey…');
     41    fetch(base + '/paid/challenge?post=' + encodeURIComponent(slug))
     42      .then(function (r) { if (!r.ok) throw { link: true }; return r.json(); })
     43      .then(function (data) {
     44        return window.SimpleWebAuthnBrowser.startAuthentication({ optionsJSON: data.options })
     45          .then(function (response) {
     46            return fetch(base + '/paid/unlock', {
     47              method: 'POST', headers: { 'Content-Type': 'application/json' },
     48              body: JSON.stringify({ response: response, blob: data.blob }),
     49            });
     50          });
     51      })
     52      .then(function (r) { return r.json().then(function (j) { return { status: r.status, j: j }; }); })
     53      .then(function (res) {
     54        if (res.j && res.j.ok) {
     55          // Swap the gate for the full post, client-side (no cookie kept).
     56          var h = document.createElement('div');
     57          h.innerHTML = (res.j.title ? '<h1 class="post-title">' + res.j.title + '</h1>' : '') +
     58            '<div class="post-content">' + res.j.html + '</div>';
     59          page.replaceWith(h);
     60        } else if (res.status === 403) {
     61          toLink();   // no valid passkey yet (or lapsed tier): link via Patreon
     62        } else {
     63          btn.disabled = false; say('Ontgrendelen mislukt. Probeer opnieuw.', true);
     64        }
     65      })
     66      .catch(function (e) {
     67        if (e && e.link) { toLink(); return; }
     68        if (e && e.name === 'NotAllowedError') { toLink(); return; }   // cancelled / no passkey -> link
     69        btn.disabled = false; say('Er ging iets mis. Probeer opnieuw.', true);
     70      });
     71  });
     72})();
     73</script>
    2374
    2475<style>
     
    3283  .pg-h2 { font-size: 22px; margin: 0 0 8px; }
    3384  .pg-sub { opacity: .85; line-height: 1.6; margin: 0 auto 12px; max-width: 34em; }
    34   .pg-soon { display: inline-block; padding: 10px 18px; border-radius: 10px; font-weight: 600;
    35     background: color-mix(in srgb, var(--accent, #6b8f71) 14%, transparent); color: var(--ink, inherit); }
     85  .pg-btn { padding: 12px 24px; border: none; border-radius: 10px; font: inherit; font-weight: 600; cursor: pointer;
     86    background: var(--accent, #6b8f71); color: #fff; }
     87  .pg-btn:disabled { opacity: .6; cursor: default; }
     88  .pg-status { margin: 12px 0 0; opacity: .9; }
     89  .pg-status.is-err { color: #c0392b; }
    3690</style>
Note: See TracChangeset for help on using the changeset viewer.