source: Klonkt/src/services/PaidPatreonService.js@ 6cbd014

main
Last change on this file since 6cbd014 was 9e9e6f9, checked in by Robin <roboburr@…>, 7 weeks ago

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

  • Property mode set to 100644
File size: 7.5 KB
RevLine 
[61e3daf]1// Paid posts (klonkt-demo-aki) slice 1: the site owner's own Patreon campaign.
2// Stores client id/secret + the creator access/refresh token (encrypted) and a
3// default price. Separate from PatreonService, which is Klonkt Premium's
4// instance-level license flow and stays untouched.
5import db from '../config/database.js';
6import { encrypt, decrypt, cryptoBoxReady } from './CryptoBox.js';
7
8const TOKEN_URL = 'https://www.patreon.com/api/oauth2/token';
9
10// The owner's config, secrets decrypted. Returns null when unconfigured.
11export function getOwnerConfig(siteId) {
12 const row = db.prepare('SELECT * FROM paid_patreon WHERE site_id = ?').get(siteId);
13 if (!row) return null;
14 return {
15 siteId: row.site_id,
16 clientId: row.client_id || null,
17 clientSecret: row.client_secret_enc ? safeDecrypt(row.client_secret_enc) : null,
18 campaignId: row.campaign_id || null,
19 accessToken: row.access_token_enc ? safeDecrypt(row.access_token_enc) : null,
20 refreshToken: row.refresh_token_enc ? safeDecrypt(row.refresh_token_enc) : null,
21 tokenExp: row.token_exp || 0,
22 defaultMinCents: row.default_min_cents || 0,
23 };
24}
25
26// Non-secret status for the admin screen (never returns tokens/secret).
27export function ownerStatus(siteId) {
28 const c = getOwnerConfig(siteId);
29 if (!c) return { configured: false, connected: false, defaultMinCents: 0 };
30 return {
31 configured: !!(c.clientId && c.clientSecret),
32 connected: !!(c.accessToken && c.campaignId),
33 clientId: c.clientId || null,
34 campaignId: c.campaignId || null,
35 defaultMinCents: c.defaultMinCents || 0,
36 tokenExp: c.tokenExp || 0,
37 hasSecret: !!c.clientSecret,
38 };
39}
40
41// Upsert. Only overwrites secret/token fields when a new value is provided, so
42// the admin form can be re-saved without re-pasting the secret.
43export function saveOwnerConfig(siteId, patch) {
44 if (!cryptoBoxReady()) throw new Error('PAID_SECRET is not set: cannot store Patreon secrets');
45 const cur = getOwnerConfig(siteId) || {};
46 const merged = {
47 clientId: patch.clientId ?? cur.clientId ?? null,
48 clientSecret: patch.clientSecret ?? cur.clientSecret ?? null,
49 campaignId: patch.campaignId ?? cur.campaignId ?? null,
50 accessToken: patch.accessToken ?? cur.accessToken ?? null,
51 refreshToken: patch.refreshToken ?? cur.refreshToken ?? null,
52 tokenExp: patch.tokenExp ?? cur.tokenExp ?? 0,
53 defaultMinCents: patch.defaultMinCents ?? cur.defaultMinCents ?? 0,
54 };
55 db.prepare(`INSERT INTO paid_patreon
56 (site_id, client_id, client_secret_enc, campaign_id, access_token_enc, refresh_token_enc, token_exp, default_min_cents, updated_at)
57 VALUES (?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)
58 ON CONFLICT(site_id) DO UPDATE SET
59 client_id=excluded.client_id, client_secret_enc=excluded.client_secret_enc,
60 campaign_id=excluded.campaign_id, access_token_enc=excluded.access_token_enc,
61 refresh_token_enc=excluded.refresh_token_enc, token_exp=excluded.token_exp,
62 default_min_cents=excluded.default_min_cents, updated_at=CURRENT_TIMESTAMP`)
63 .run(
64 siteId,
65 merged.clientId,
66 merged.clientSecret != null ? encrypt(merged.clientSecret) : null,
67 merged.campaignId,
68 merged.accessToken != null ? encrypt(merged.accessToken) : null,
69 merged.refreshToken != null ? encrypt(merged.refreshToken) : null,
70 merged.tokenExp || 0,
71 Math.max(0, parseInt(merged.defaultMinCents, 10) || 0),
72 );
73}
74
75export function disconnect(siteId) {
76 db.prepare('DELETE FROM paid_patreon WHERE site_id = ?').run(siteId);
77}
78
79export function defaultMinCents(siteId) {
80 const row = db.prepare('SELECT default_min_cents FROM paid_patreon WHERE site_id = ?').get(siteId);
81 return row ? (row.default_min_cents || 0) : 0;
82}
83
84// True when the stored creator token is missing or within `skewSeconds` of exp.
85export function needsRefresh(siteId, skewSeconds = 3600) {
86 const c = getOwnerConfig(siteId);
87 if (!c || !c.refreshToken) return false;
88 return !c.accessToken || (c.tokenExp || 0) <= (Math.floor(Date.now() / 1000) + skewSeconds);
89}
90
91// Refresh the creator token via Patreon. Returns true on success. `fetchImpl`
92// is injectable for tests; defaults to global fetch.
93export async function refreshCreatorToken(siteId, fetchImpl = fetch) {
94 const c = getOwnerConfig(siteId);
95 if (!c || !c.clientId || !c.clientSecret || !c.refreshToken) return false;
96 const body = new URLSearchParams({
97 grant_type: 'refresh_token',
98 refresh_token: c.refreshToken,
99 client_id: c.clientId,
100 client_secret: c.clientSecret,
101 });
102 const res = await fetchImpl(TOKEN_URL, {
103 method: 'POST',
104 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
105 body: body.toString(),
106 });
107 if (!res.ok) return false;
108 const j = await res.json();
109 if (!j || !j.access_token) return false;
110 saveOwnerConfig(siteId, {
111 accessToken: j.access_token,
112 refreshToken: j.refresh_token || c.refreshToken,
113 tokenExp: Math.floor(Date.now() / 1000) + (parseInt(j.expires_in, 10) || 0),
114 });
115 return true;
116}
117
118// A valid creator access token, refreshing first if it is stale. Null when the
119// owner has not connected. Used by the patron verify path (slice 3).
120export async function creatorAccessToken(siteId, fetchImpl = fetch) {
121 if (needsRefresh(siteId)) { try { await refreshCreatorToken(siteId, fetchImpl); } catch { /* fall through */ } }
122 const c = getOwnerConfig(siteId);
123 return c && c.accessToken ? c.accessToken : null;
124}
125
[9e9e6f9]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
[61e3daf]167function safeDecrypt(blob) {
168 try { return decrypt(blob); } catch { return null; }
169}
170
171export default {
172 getOwnerConfig, ownerStatus, saveOwnerConfig, disconnect,
173 defaultMinCents, needsRefresh, refreshCreatorToken, creatorAccessToken,
[9e9e6f9]174 pickCampaignMembership, verifyPatron,
[61e3daf]175};
Note: See TracBrowser for help on using the repository browser.