Changeset c7a211d in Klonkt


Ignore:
Timestamp:
07/21/2026 02:57:49 AM (7 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
ec288dc
Parents:
9019a3e
git-author:
Robin <roboburr@…> (07/21/2026 02:57:46 AM)
git-committer:
Robin <roboburr@…> (07/21/2026 02:57:49 AM)
Message:

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

Files:
2 edited

Legend:

Unmodified
Added
Removed
  • src/services/PaidPatreonService.js

    r9019a3e rc7a211d  
    135135}
    136136
    137 // Pure: pick the membership for the owner's campaign out of a Patreon
     137// Pure: pick the owner's-campaign membership out of a Patreon
    138138// identity?include=memberships.campaign response (JSON:API). Returns
    139139// { 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.
    140146export function pickCampaignMembership(identity, campaignId) {
    141147  const inc = (identity && identity.included) || [];
     148  const members = [];
    142149  for (const it of inc) {
    143150    if (it.type !== 'member') continue;
    144151    const camp = it.relationships && it.relationships.campaign && it.relationships.campaign.data;
    145     if (!camp || String(camp.id) !== String(campaignId)) continue;
    146152    const a = it.attributes || {};
    147     return { status: a.patron_status || null, cents: a.currently_entitled_amount_cents || 0 };
     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    });
    148158  }
    149   return null;
     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;
    150163}
    151164
     
    173186  if (!idRes.ok) return null;
    174187  const identity = await idRes.json();
    175   return pickCampaignMembership(identity, c.campaignId);   // token goes out of scope, discarded
     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;
    176203}
    177204
  • test/paid-patron.test.js

    r9019a3e rc7a211d  
    2727});
    2828
    29 test('pickCampaignMembership finds the right campaign, ignores others', async () => {
     29test('pickCampaignMembership: exact campaign match wins', async () => {
    3030  const { pickCampaignMembership } = await import('../src/services/PaidPatreonService.js');
    3131  const id = identityWith('42', 'active_patron', 500);
     
    3333  assert.equal(m.status, 'active_patron');
    3434  assert.equal(m.cents, 500);
    35   assert.equal(pickCampaignMembership(id, '999'), null);   // different campaign
     35});
     36
     37test('pickCampaignMembership: sole membership is used even if campaign_id is wrong (creator-scoped)', async () => {
     38  const { pickCampaignMembership } = await import('../src/services/PaidPatreonService.js');
     39  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);
     45});
     46
     47test('pickCampaignMembership: multiple memberships + no match → null (no silent grant)', async () => {
     48  const { pickCampaignMembership } = await import('../src/services/PaidPatreonService.js');
     49  const id = {
     50    data: { type: 'user', id: 'u', relationships: { memberships: { data: [{ type: 'member', id: 'm1' }, { type: 'member', id: 'm2' }] } } },
     51    included: [
     52      { type: 'member', id: 'm1', attributes: { patron_status: 'active_patron', currently_entitled_amount_cents: 300 }, relationships: { campaign: { data: { type: 'campaign', id: '42' } } } },
     53      { type: 'member', id: 'm2', attributes: { patron_status: 'active_patron', currently_entitled_amount_cents: 800 }, relationships: { campaign: { data: { type: 'campaign', id: '77' } } } },
     54    ],
     55  };
     56  assert.equal(pickCampaignMembership(id, '999'), null);      // ambiguous, refuse
     57  assert.equal(pickCampaignMembership(id, '77').cents, 800);  // exact still works
     58});
     59
     60test('pickCampaignMembership: no memberships → null', async () => {
     61  const { pickCampaignMembership } = await import('../src/services/PaidPatreonService.js');
     62  assert.equal(pickCampaignMembership({ data: {}, included: [] }, '42'), null);
    3663});
    3764
Note: See TracChangeset for help on using the changeset viewer.