Changeset 9e9e6f9 in Klonkt for src/services


Ignore:
Timestamp:
07/21/2026 01:16:03 AM (7 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
d43230f
Parents:
928d1c7
git-author:
Robin <roboburr@…> (07/21/2026 01:15:39 AM)
git-committer:
Robin <roboburr@…> (07/21/2026 01:16:03 AM)
Message:

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

Location:
src/services
Files:
1 added
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/PaidPatreonService.js

    r928d1c7 r9e9e6f9  
    124124}
    125125
     126// Pure: pick the membership for the owner's campaign out of a Patreon
     127// identity?include=memberships.campaign response (JSON:API). Returns
     128// { status, cents } or null.
     129export function pickCampaignMembership(identity, campaignId) {
     130  const inc = (identity && identity.included) || [];
     131  for (const it of inc) {
     132    if (it.type !== 'member') continue;
     133    const camp = it.relationships && it.relationships.campaign && it.relationships.campaign.data;
     134    if (!camp || String(camp.id) !== String(campaignId)) continue;
     135    const a = it.attributes || {};
     136    return { status: a.patron_status || null, cents: a.currently_entitled_amount_cents || 0 };
     137  }
     138  return null;
     139}
     140
     141// Exchange a patron's auth code and read their membership of the owner's
     142// campaign. Returns { status, cents } or null. The patron token is used once
     143// and discarded here: nothing identifying is stored (design decision).
     144export async function verifyPatron(siteId, code, redirectUri, fetchImpl = fetch) {
     145  const c = getOwnerConfig(siteId);
     146  if (!c || !c.clientId || !c.clientSecret || !c.campaignId) return null;
     147  const tokenRes = await fetchImpl(TOKEN_URL, {
     148    method: 'POST',
     149    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
     150    body: new URLSearchParams({
     151      grant_type: 'authorization_code', code,
     152      client_id: c.clientId, client_secret: c.clientSecret, redirect_uri: redirectUri,
     153    }).toString(),
     154  });
     155  if (!tokenRes.ok) return null;
     156  const tok = await tokenRes.json();
     157  if (!tok || !tok.access_token) return null;
     158  const url = 'https://www.patreon.com/api/oauth2/v2/identity'
     159    + '?include=memberships.campaign'
     160    + '&fields%5Bmember%5D=patron_status,currently_entitled_amount_cents';
     161  const idRes = await fetchImpl(url, { headers: { Authorization: `Bearer ${tok.access_token}` } });
     162  if (!idRes.ok) return null;
     163  const identity = await idRes.json();
     164  return pickCampaignMembership(identity, c.campaignId);   // token goes out of scope, discarded
     165}
     166
    126167function safeDecrypt(blob) {
    127168  try { return decrypt(blob); } catch { return null; }
     
    131172  getOwnerConfig, ownerStatus, saveOwnerConfig, disconnect,
    132173  defaultMinCents, needsRefresh, refreshCreatorToken, creatorAccessToken,
     174  pickCampaignMembership, verifyPatron,
    133175};
Note: See TracChangeset for help on using the changeset viewer.