Changeset 9e9e6f9 in Klonkt for src


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
Files:
5 added
3 edited

Legend:

Unmodified
Added
Removed
  • src/config/database.js

    r928d1c7 r9e9e6f9  
    323323      default_min_cents INTEGER DEFAULT 0,
    324324      updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
     325    );
     326    -- One row per passkey. NO patron identity is stored (design decision):
     327    -- {passkey, site, proven cents, expiry}. Not traceable to a person.
     328    CREATE TABLE IF NOT EXISTS paid_entitlements (
     329      credential_id TEXT PRIMARY KEY,   -- WebAuthn credential id (opaque, base64url)
     330      site_id TEXT NOT NULL,
     331      public_key TEXT NOT NULL,         -- COSE public key, base64url
     332      counter INTEGER DEFAULT 0,
     333      transports TEXT,
     334      min_cents INTEGER DEFAULT 0,      -- the amount proven at link time
     335      expires_at INTEGER NOT NULL,      -- unix seconds; re-link after
     336      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
    325337    );
    326338    CREATE TABLE IF NOT EXISTS ap_outbox (
  • src/server.js

    r928d1c7 r9e9e6f9  
    4141import feedRoutes from './routes/feed.js';
    4242import postsRoutes from './routes/posts.js';
     43import paidRoutes from './routes/paid.js';
    4344import langRoutes from './routes/lang.js';
    4445import adminUpdatesRoutes from './routes/admin-updates.js';
     
    400401app.use('/', changelogRoutes); // /changelog publieke release-/wijzigingen-pagina
    401402app.use('/', langRoutes); // /lang/:code — interface-taal kiezen (vóór de catch-all)
     403app.use('/paid', paidRoutes);   // paid-posts patron/passkey flow (before the /:slug catch-all)
    402404app.use('/', postsRoutes);
    403405
  • 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.