Changeset dede82e in Klonkt


Ignore:
Timestamp:
07/21/2026 03:17:16 AM (7 weeks ago)
Author:
Robin <roboburr@…>
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)
Message:

Fix: derive the owner's campaign id from the creator token (strict match)

The tester's diagnose line was the smoking gun: config campaign=29148518,
but his 12 memberships (Patreon /identity returns ALL memberships across
every creator, not just this one) contained no 29148518. His real, active
pledges were to 1373144 and 16300989. So the admin campaign_id was simply
wrong, and a wrong id locks out real patrons.

Two corrections:

  • Drop the sole-membership fallback added earlier. It was based on the wrong assumption that /identity is creator-scoped; with global memberships it would grant access to someone backing a DIFFERENT creator. pickCampaign- Membership is strict again: exact campaign match or nothing.
  • Auto-derive the authoritative campaign id from the creator token (GET /campaigns returns the owner's own campaign), prefer it over the typed value, and self-heal the stored config when they differ. The owner no longer has to find/enter the campaign id by hand.

The diagnose line now shows owner=<from token> config=<typed> so a mismatch
is obvious.

Changed files:
src/services/PaidPatreonService.js

  • pickCampaignMembership strict; fetchOwnerCampaignId (creator token); verifyPatron derives + self-heals the campaign id, richer diag

test/paid-patron.test.js

  • strict match tests; fetchOwnerCampaignId with mock fetch

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

Files:
2 edited

Legend:

Unmodified
Added
Removed
  • src/services/PaidPatreonService.js

    rec288dc rdede82e  
    139139// { status, cents } or null.
    140140//
    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.
    146147export function pickCampaignMembership(identity, campaignId) {
     148  if (!campaignId) return null;
    147149  const inc = (identity && identity.included) || [];
    148   const members = [];
    149150  for (const it of inc) {
    150151    if (it.type !== 'member') continue;
    151152    const camp = it.relationships && it.relationships.campaign && it.relationships.campaign.data;
     153    if (!camp || String(camp.id) !== String(campaignId)) continue;
    152154    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 };
    158156  }
    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.
     163export 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;
    163173}
    164174
     
    170180export async function verifyPatron(siteId, code, redirectUri, fetchImpl = fetch) {
    171181  const c = getOwnerConfig(siteId);
    172   if (!c || !c.clientId || !c.clientSecret || !c.campaignId) return null;
     182  if (!c || !c.clientId || !c.clientSecret) return null;
    173183  const none = (diag) => ({ status: null, cents: 0, diag });
    174184  let tokenRes;
     
    192202  if (!idRes.ok) return none(`identity_http_${idRes.status}`);
    193203  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
    195212  const seen = ((identity && identity.included) || [])
    196213    .filter((it) => it.type === 'member')
     
    200217      return `${camp ? camp.id : '?'}:${a.patron_status || 'null'}:${a.currently_entitled_amount_cents || 0}c`;
    201218    });
    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'}`;
    203220  if (!membership || membership.status !== 'active_patron') console.warn(`[paid] verifyPatron: ${diag}`);
    204221  return { status: membership ? membership.status : null, cents: membership ? membership.cents : 0, diag };
     
    212229  getOwnerConfig, ownerStatus, saveOwnerConfig, disconnect,
    213230  defaultMinCents, patreonUrl, needsRefresh, refreshCreatorToken, creatorAccessToken,
    214   pickCampaignMembership, verifyPatron,
     231  pickCampaignMembership, fetchOwnerCampaignId, verifyPatron,
    215232};
  • test/paid-patron.test.js

    rec288dc rdede82e  
    3535});
    3636
    37 test('pickCampaignMembership: sole membership is used even if campaign_id is wrong (creator-scoped)', async () => {
     37test('pickCampaignMembership: STRICT — a non-matching campaign_id is refused', async () => {
    3838  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.
    3941  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
    4545});
    4646
    47 test('pickCampaignMembership: multiple memberships + no match → null (no silent grant)', async () => {
     47test('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
     57test('pickCampaignMembership: many memberships, only the exact campaign matches', async () => {
    4858  const { pickCampaignMembership } = await import('../src/services/PaidPatreonService.js');
    4959  const id = {
     
    5464    ],
    5565  };
    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);
    5869});
    5970
Note: See TracChangeset for help on using the changeset viewer.