source: Klonkt/src/services/PaidPatreonService.js@ c7a211d

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

Fix: real supporters were rejected on a wrong campaign_id + add diagnostics

Robin is an active supporter but /paid/callback said "Nog geen supporter",
so he never reached the passkey step (that page only shows after a verified
patron). Root cause: pickCampaignMembership matched STRICTLY on the owner's
configured campaign_id, and a wrong/typo'd id in the admin locked out real
patrons.

Memberships returned via a creator's OWN OAuth client are already scoped to
that creator's campaign(s), so:

  • prefer an exact campaign_id match (unchanged for the common case),
  • but fall back to the sole membership when the configured id doesn't match,
  • refuse only when it's genuinely ambiguous (multiple memberships, none matching) so we never silently grant the wrong one.

Also log a one-line diagnostic in verifyPatron when a patron is NOT accepted:
the campaign_id configured vs the memberships Patreon actually returned
(campaign:status:cents). Stores nothing; it's a server log so the owner can
see whether their campaign_id is wrong or the pledge isn't active.

Changed files:
src/services/PaidPatreonService.js

  • pickCampaignMembership: exact match, else sole-membership fallback, else null; verifyPatron logs why a patron was rejected

test/paid-patron.test.js

  • exact match, sole-membership fallback, ambiguous→null, empty→null

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

  • Property mode set to 100644
File size: 9.7 KB
Line 
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 patreonUrl: row.patreon_url || null,
24 };
25}
26
27// Non-secret status for the admin screen (never returns tokens/secret).
28export function ownerStatus(siteId) {
29 const c = getOwnerConfig(siteId);
30 if (!c) return { configured: false, connected: false, defaultMinCents: 0 };
31 return {
32 configured: !!(c.clientId && c.clientSecret),
33 connected: !!(c.accessToken && c.campaignId),
34 clientId: c.clientId || null,
35 campaignId: c.campaignId || null,
36 defaultMinCents: c.defaultMinCents || 0,
37 tokenExp: c.tokenExp || 0,
38 hasSecret: !!c.clientSecret,
39 patreonUrl: c.patreonUrl || null,
40 };
41}
42
43// The owner's public Patreon page, for the "Word supporter" link. Null when unset.
44export function patreonUrl(siteId) {
45 const c = getOwnerConfig(siteId);
46 return c && c.patreonUrl ? c.patreonUrl : null;
47}
48
49// Upsert. Only overwrites secret/token fields when a new value is provided, so
50// the admin form can be re-saved without re-pasting the secret.
51export function saveOwnerConfig(siteId, patch) {
52 if (!cryptoBoxReady()) throw new Error('PAID_SECRET is not set: cannot store Patreon secrets');
53 const cur = getOwnerConfig(siteId) || {};
54 const merged = {
55 clientId: patch.clientId ?? cur.clientId ?? null,
56 clientSecret: patch.clientSecret ?? cur.clientSecret ?? null,
57 campaignId: patch.campaignId ?? cur.campaignId ?? null,
58 accessToken: patch.accessToken ?? cur.accessToken ?? null,
59 refreshToken: patch.refreshToken ?? cur.refreshToken ?? null,
60 tokenExp: patch.tokenExp ?? cur.tokenExp ?? 0,
61 defaultMinCents: patch.defaultMinCents ?? cur.defaultMinCents ?? 0,
62 // undefined = keep (e.g. token refresh doesn't touch it); null/'' = clear.
63 patreonUrl: patch.patreonUrl !== undefined ? (patch.patreonUrl || null) : (cur.patreonUrl ?? null),
64 };
65 db.prepare(`INSERT INTO paid_patreon
66 (site_id, client_id, client_secret_enc, campaign_id, access_token_enc, refresh_token_enc, token_exp, default_min_cents, patreon_url, updated_at)
67 VALUES (?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)
68 ON CONFLICT(site_id) DO UPDATE SET
69 client_id=excluded.client_id, client_secret_enc=excluded.client_secret_enc,
70 campaign_id=excluded.campaign_id, access_token_enc=excluded.access_token_enc,
71 refresh_token_enc=excluded.refresh_token_enc, token_exp=excluded.token_exp,
72 default_min_cents=excluded.default_min_cents, patreon_url=excluded.patreon_url, updated_at=CURRENT_TIMESTAMP`)
73 .run(
74 siteId,
75 merged.clientId,
76 merged.clientSecret != null ? encrypt(merged.clientSecret) : null,
77 merged.campaignId,
78 merged.accessToken != null ? encrypt(merged.accessToken) : null,
79 merged.refreshToken != null ? encrypt(merged.refreshToken) : null,
80 merged.tokenExp || 0,
81 Math.max(0, parseInt(merged.defaultMinCents, 10) || 0),
82 merged.patreonUrl || null,
83 );
84}
85
86export function disconnect(siteId) {
87 db.prepare('DELETE FROM paid_patreon WHERE site_id = ?').run(siteId);
88}
89
90export function defaultMinCents(siteId) {
91 const row = db.prepare('SELECT default_min_cents FROM paid_patreon WHERE site_id = ?').get(siteId);
92 return row ? (row.default_min_cents || 0) : 0;
93}
94
95// True when the stored creator token is missing or within `skewSeconds` of exp.
96export function needsRefresh(siteId, skewSeconds = 3600) {
97 const c = getOwnerConfig(siteId);
98 if (!c || !c.refreshToken) return false;
99 return !c.accessToken || (c.tokenExp || 0) <= (Math.floor(Date.now() / 1000) + skewSeconds);
100}
101
102// Refresh the creator token via Patreon. Returns true on success. `fetchImpl`
103// is injectable for tests; defaults to global fetch.
104export async function refreshCreatorToken(siteId, fetchImpl = fetch) {
105 const c = getOwnerConfig(siteId);
106 if (!c || !c.clientId || !c.clientSecret || !c.refreshToken) return false;
107 const body = new URLSearchParams({
108 grant_type: 'refresh_token',
109 refresh_token: c.refreshToken,
110 client_id: c.clientId,
111 client_secret: c.clientSecret,
112 });
113 const res = await fetchImpl(TOKEN_URL, {
114 method: 'POST',
115 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
116 body: body.toString(),
117 });
118 if (!res.ok) return false;
119 const j = await res.json();
120 if (!j || !j.access_token) return false;
121 saveOwnerConfig(siteId, {
122 accessToken: j.access_token,
123 refreshToken: j.refresh_token || c.refreshToken,
124 tokenExp: Math.floor(Date.now() / 1000) + (parseInt(j.expires_in, 10) || 0),
125 });
126 return true;
127}
128
129// A valid creator access token, refreshing first if it is stale. Null when the
130// owner has not connected. Used by the patron verify path (slice 3).
131export async function creatorAccessToken(siteId, fetchImpl = fetch) {
132 if (needsRefresh(siteId)) { try { await refreshCreatorToken(siteId, fetchImpl); } catch { /* fall through */ } }
133 const c = getOwnerConfig(siteId);
134 return c && c.accessToken ? c.accessToken : null;
135}
136
137// Pure: pick the owner's-campaign membership out of a Patreon
138// identity?include=memberships.campaign response (JSON:API). Returns
139// { status, cents } or null.
140//
141// Memberships returned via a creator's OWN OAuth client are already scoped to
142// that creator's campaign(s), so in practice there is one. We still prefer an
143// exact campaign_id match (belt and suspenders for multi-campaign creators),
144// but fall back to the sole membership when the configured campaign_id doesn't
145// match: a wrong/typo'd campaign_id in the admin must not lock out real patrons.
146export function pickCampaignMembership(identity, campaignId) {
147 const inc = (identity && identity.included) || [];
148 const members = [];
149 for (const it of inc) {
150 if (it.type !== 'member') continue;
151 const camp = it.relationships && it.relationships.campaign && it.relationships.campaign.data;
152 const a = it.attributes || {};
153 members.push({
154 status: a.patron_status || null,
155 cents: a.currently_entitled_amount_cents || 0,
156 campaignId: camp ? String(camp.id) : null,
157 });
158 }
159 if (!members.length) return null;
160 const exact = members.find((m) => m.campaignId && String(m.campaignId) === String(campaignId));
161 const pick = exact || (members.length === 1 ? members[0] : null);
162 return pick ? { status: pick.status, cents: pick.cents } : null;
163}
164
165// Exchange a patron's auth code and read their membership of the owner's
166// campaign. Returns { status, cents } or null. The patron token is used once
167// and discarded here: nothing identifying is stored (design decision).
168export async function verifyPatron(siteId, code, redirectUri, fetchImpl = fetch) {
169 const c = getOwnerConfig(siteId);
170 if (!c || !c.clientId || !c.clientSecret || !c.campaignId) return null;
171 const tokenRes = await fetchImpl(TOKEN_URL, {
172 method: 'POST',
173 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
174 body: new URLSearchParams({
175 grant_type: 'authorization_code', code,
176 client_id: c.clientId, client_secret: c.clientSecret, redirect_uri: redirectUri,
177 }).toString(),
178 });
179 if (!tokenRes.ok) return null;
180 const tok = await tokenRes.json();
181 if (!tok || !tok.access_token) return null;
182 const url = 'https://www.patreon.com/api/oauth2/v2/identity'
183 + '?include=memberships.campaign'
184 + '&fields%5Bmember%5D=patron_status,currently_entitled_amount_cents';
185 const idRes = await fetchImpl(url, { headers: { Authorization: `Bearer ${tok.access_token}` } });
186 if (!idRes.ok) return null;
187 const identity = await idRes.json();
188 const membership = pickCampaignMembership(identity, c.campaignId); // token goes out of scope, discarded
189 // Diagnostic (no identity stored, only shapes): why did a real supporter get
190 // rejected? Logs the memberships Patreon returned vs the configured campaign.
191 // Nothing here is persisted; it's a one-line server log for the owner.
192 if (!membership || membership.status !== 'active_patron') {
193 const seen = ((identity && identity.included) || [])
194 .filter((it) => it.type === 'member')
195 .map((it) => {
196 const camp = it.relationships && it.relationships.campaign && it.relationships.campaign.data;
197 const a = it.attributes || {};
198 return `${camp ? camp.id : '?'}:${a.patron_status || 'null'}:${a.currently_entitled_amount_cents || 0}c`;
199 });
200 console.warn(`[paid] verifyPatron: config campaign=${c.campaignId} → memberships seen=[${seen.join(', ') || 'none'}] picked=${membership ? membership.status + '/' + membership.cents + 'c' : 'null'}`);
201 }
202 return membership;
203}
204
205function safeDecrypt(blob) {
206 try { return decrypt(blob); } catch { return null; }
207}
208
209export default {
210 getOwnerConfig, ownerStatus, saveOwnerConfig, disconnect,
211 defaultMinCents, patreonUrl, needsRefresh, refreshCreatorToken, creatorAccessToken,
212 pickCampaignMembership, verifyPatron,
213};
Note: See TracBrowser for help on using the repository browser.