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