Changeset 6cbd014 in Klonkt for src/routes/paid.js


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/routes/paid.js

    rd43230f r6cbd014  
    1515import PaidPatreon from '../services/PaidPatreonService.js';
    1616import Passkey from '../services/PasskeyService.js';
     17import { renderPostBodyHtml } from './posts.js';
    1718
    1819const router = express.Router();
     
    9596});
    9697
     98// Step 4 (unlock): hand out authentication options for a passkey assertion.
     99router.get('/challenge', async (req, res) => {
     100  const r = ready(req, res); if (!r) return;
     101  const slug = String(req.query.post || '').trim();
     102  const post = slug ? db.prepare('SELECT slug, paid, paid_min_cents FROM posts WHERE site_id = ? AND slug = ?').get(r.site.id, slug) : null;
     103  if (!post || !post.paid) return res.status(404).json({ error: 'not_paid' });
     104  const cents = post.paid_min_cents || PaidPatreon.defaultMinCents(r.site.id);
     105  const options = await Passkey.authenticationOptions(baseUrl(req));
     106  const blob = signBlob({ purpose: 'auth', siteId: r.site.id, cents, post: post.slug, challenge: options.challenge }, 300);
     107  res.json({ options, blob });
     108});
     109
     110// Verify the assertion, check the entitlement, and return the full post body in
     111// the SAME response. No unlock token becomes state (design decision).
     112router.post('/unlock', express.json({ limit: '64kb' }), async (req, res) => {
     113  const r = ready(req, res); if (!r) return res.status(404).json({ error: 'unavailable' });
     114  const { response, blob } = req.body || {};
     115  const payload = verifyBlob(String(blob || ''));
     116  if (!payload || payload.purpose !== 'auth' || payload.siteId !== r.site.id) return res.status(400).json({ error: 'bad_challenge' });
     117  const credId = response && response.id;
     118  const ent = credId ? Passkey.getEntitlement(credId, r.site.id) : null;
     119  if (!ent) return res.status(403).json({ error: 'no_entitlement' });      // unknown/expired passkey
     120  if ((ent.min_cents || 0) < payload.cents) return res.status(403).json({ error: 'tier' });
     121  const vr = await Passkey.verifyAssertion(baseUrl(req), response, payload.challenge, ent);
     122  if (!vr) return res.status(400).json({ error: 'verify_failed' });
     123  Passkey.bumpCounter(credId, vr.newCounter);
     124  const post = db.prepare("SELECT * FROM posts WHERE site_id = ? AND slug = ? AND status = 'published'").get(r.site.id, String(payload.post || ''));
     125  if (!post || !post.paid) return res.status(404).json({ error: 'gone' });
     126  res.json({ ok: true, title: post.title || '', html: renderPostBodyHtml(r.site, post, req) });
     127});
     128
    97129export default router;
Note: See TracChangeset for help on using the changeset viewer.