Changeset dede82e in Klonkt
- Timestamp:
- 07/21/2026 03:17:16 AM (7 weeks ago)
- Branches:
- main
- Children:
- 072a242
- Parents:
- ec288dc
- git-author:
- Robin <roboburr@…> (07/21/2026 03:17:13 AM)
- git-committer:
- Robin <roboburr@…> (07/21/2026 03:17:16 AM)
- Files:
-
- 2 edited
-
src/services/PaidPatreonService.js (modified) (5 diffs)
-
test/paid-patron.test.js (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/services/PaidPatreonService.js
rec288dc rdede82e 139 139 // { status, cents } or null. 140 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. 141 // STRICT match on campaignId only. Patreon's /identity returns ALL of the 142 // visitor's memberships across every creator they back (verified: a tester had 143 // 12), NOT just this creator's, so any fallback would grant access to someone 144 // who backs a DIFFERENT creator. The campaignId must therefore be the owner's 145 // real campaign; verifyPatron auto-derives it from the creator token so a 146 // mistyped admin value can't lock real patrons out. 146 147 export function pickCampaignMembership(identity, campaignId) { 148 if (!campaignId) return null; 147 149 const inc = (identity && identity.included) || []; 148 const members = [];149 150 for (const it of inc) { 150 151 if (it.type !== 'member') continue; 151 152 const camp = it.relationships && it.relationships.campaign && it.relationships.campaign.data; 153 if (!camp || String(camp.id) !== String(campaignId)) continue; 152 154 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 }); 155 return { status: a.patron_status || null, cents: a.currently_entitled_amount_cents || 0 }; 158 156 } 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; 157 return null; 158 } 159 160 // The campaign id owned by the creator token (i.e. the site owner's OWN 161 // campaign). This is authoritative: it removes the "typed the wrong campaign_id" 162 // failure mode. Null if there's no valid creator token or the call fails. 163 export async function fetchOwnerCampaignId(siteId, fetchImpl = fetch) { 164 const token = await creatorAccessToken(siteId, fetchImpl).catch(() => null); 165 if (!token) return null; 166 const res = await fetchImpl('https://www.patreon.com/api/oauth2/v2/campaigns', { 167 headers: { Authorization: `Bearer ${token}` }, 168 }).catch(() => null); 169 if (!res || !res.ok) return null; 170 const j = await res.json().catch(() => null); 171 const id = j && j.data && j.data[0] && j.data[0].id; 172 return id ? String(id) : null; 163 173 } 164 174 … … 170 180 export async function verifyPatron(siteId, code, redirectUri, fetchImpl = fetch) { 171 181 const c = getOwnerConfig(siteId); 172 if (!c || !c.clientId || !c.clientSecret || !c.campaignId) return null;182 if (!c || !c.clientId || !c.clientSecret) return null; 173 183 const none = (diag) => ({ status: null, cents: 0, diag }); 174 184 let tokenRes; … … 192 202 if (!idRes.ok) return none(`identity_http_${idRes.status}`); 193 203 const identity = await idRes.json(); 194 const membership = pickCampaignMembership(identity, c.campaignId); // token goes out of scope, discarded 204 // Authoritative campaign id: the one owned by the creator token. Beats a 205 // mistyped admin value. Self-heal the stored config when they differ. 206 const ownerCampaign = await fetchOwnerCampaignId(siteId, fetchImpl).catch(() => null); 207 const campaignId = ownerCampaign || c.campaignId; 208 if (ownerCampaign && String(ownerCampaign) !== String(c.campaignId)) { 209 try { saveOwnerConfig(siteId, { campaignId: ownerCampaign }); } catch { /* non-fatal */ } 210 } 211 const membership = pickCampaignMembership(identity, campaignId); // token goes out of scope, discarded 195 212 const seen = ((identity && identity.included) || []) 196 213 .filter((it) => it.type === 'member') … … 200 217 return `${camp ? camp.id : '?'}:${a.patron_status || 'null'}:${a.currently_entitled_amount_cents || 0}c`; 201 218 }); 202 const diag = ` campaign=${c.campaignId} seen=[${seen.join(', ') || 'none'}] picked=${membership ? membership.status + '/' + membership.cents + 'c' : 'null'}`;219 const diag = `owner=${ownerCampaign || 'unknown'} config=${c.campaignId || 'none'} seen=[${seen.join(', ') || 'none'}] picked=${membership ? membership.status + '/' + membership.cents + 'c' : 'null'}`; 203 220 if (!membership || membership.status !== 'active_patron') console.warn(`[paid] verifyPatron: ${diag}`); 204 221 return { status: membership ? membership.status : null, cents: membership ? membership.cents : 0, diag }; … … 212 229 getOwnerConfig, ownerStatus, saveOwnerConfig, disconnect, 213 230 defaultMinCents, patreonUrl, needsRefresh, refreshCreatorToken, creatorAccessToken, 214 pickCampaignMembership, verifyPatron,231 pickCampaignMembership, fetchOwnerCampaignId, verifyPatron, 215 232 }; -
test/paid-patron.test.js
rec288dc rdede82e 35 35 }); 36 36 37 test('pickCampaignMembership: sole membership is used even if campaign_id is wrong (creator-scoped)', async () => {37 test('pickCampaignMembership: STRICT — a non-matching campaign_id is refused', async () => { 38 38 const { pickCampaignMembership } = await import('../src/services/PaidPatreonService.js'); 39 // /identity returns ALL the visitor's memberships across every creator, so a 40 // membership to a DIFFERENT campaign must never grant access here. 39 41 const id = identityWith('42', 'active_patron', 500); 40 // A typo'd campaign_id must not lock out a real patron: memberships from the 41 // owner's own client are already their campaign, so fall back to the sole one. 42 const m = pickCampaignMembership(id, '999'); 43 assert.equal(m.status, 'active_patron'); 44 assert.equal(m.cents, 500); 42 assert.equal(pickCampaignMembership(id, '999'), null); // different campaign → no 43 assert.equal(pickCampaignMembership(id, ''), null); // no campaign → null 44 assert.equal(pickCampaignMembership(id, '42').cents, 500); // exact → yes 45 45 }); 46 46 47 test('pickCampaignMembership: multiple memberships + no match → null (no silent grant)', async () => { 47 test('fetchOwnerCampaignId reads the creator campaign from the creator token', async () => { 48 PP.saveOwnerConfig('s3', { clientId: 'c', clientSecret: 's', campaignId: 'WRONG', accessToken: 'creator-tok', refreshToken: 'r', tokenExp: Math.floor(Date.now() / 1000) + 99999 }); 49 const fetchMock = async (url) => { 50 if (url.includes('/campaigns')) return { ok: true, json: async () => ({ data: [{ type: 'campaign', id: '16300989' }] }) }; 51 return { ok: false, status: 404, json: async () => ({}) }; 52 }; 53 const id = await PP.fetchOwnerCampaignId('s3', fetchMock); 54 assert.equal(id, '16300989'); 55 }); 56 57 test('pickCampaignMembership: many memberships, only the exact campaign matches', async () => { 48 58 const { pickCampaignMembership } = await import('../src/services/PaidPatreonService.js'); 49 59 const id = { … … 54 64 ], 55 65 }; 56 assert.equal(pickCampaignMembership(id, '999'), null); // ambiguous, refuse 57 assert.equal(pickCampaignMembership(id, '77').cents, 800); // exact still works 66 assert.equal(pickCampaignMembership(id, '999'), null); // none of them 67 assert.equal(pickCampaignMembership(id, '42').cents, 300); 68 assert.equal(pickCampaignMembership(id, '77').cents, 800); 58 69 }); 59 70
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)