From 5c28176d30a8fc75360ab95ec7e271000d12afb4 Mon Sep 17 00:00:00 2001 From: inference-bot Date: Sat, 5 Sep 2026 22:48:58 -0600 Subject: [PATCH] Gate on live OC membership list (authoritative) instead of fragile webhook pending table --- app/main.py | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/app/main.py b/app/main.py index 766799b..1c4cc79 100644 --- a/app/main.py +++ b/app/main.py @@ -55,6 +55,7 @@ OC_OAUTH_REDIRECT_URI = os.environ.get( OC_AUTHORIZE_URL = "https://opencollective.com/oauth/authorize" OC_TOKEN_URL = "https://opencollective.com/oauth/token" OC_GRAPHQL_URL = "https://opencollective.com/api/graphql/v2" +OC_COLLECTIVE_SLUG = os.environ.get("OC_COLLECTIVE_SLUG", "inference-cooperative") # Monthly credit budget (in USD of tokens) for all members. # Single sliding-scale tier: everyone gets the same $15/month in credits, @@ -134,6 +135,31 @@ def is_pending_member(slug: str) -> bool: return row is not None +async def is_active_member(slug: str) -> bool: + """Check the LIVE Open Collective membership list (authoritative source of + truth) for whether this slug is a financial contributor (BACKER/ADMIN), + not just the fiscal host. More reliable than the webhook, which only fires + on new events and can miss existing members.""" + if not slug: + return False + query = ( + '{ collective(slug: "%s") { members(limit: 100) { nodes { role account { slug } } } } }' + % OC_COLLECTIVE_SLUG + ) + async with httpx.AsyncClient() as client: + r = await client.post(OC_GRAPHQL_URL, json={"query": query}) + if r.status_code != 200: + logger.warning("OC membership check failed: %s", r.status_code) + return False + nodes = r.json().get("data", {}).get("collective", {}).get("members", {}).get("nodes", []) + for n in nodes: + role = n.get("role", "") + acct = n.get("account", {}) or {} + if acct.get("slug") == slug and role in ("BACKER", "ADMIN"): + return True + return False + + def store_member(email: str, key_token: str, cloudron_user_id: str, slug: str = "") -> None: conn = get_db() conn.execute( @@ -530,10 +556,11 @@ async def oauth_callback(request: Request): if not email: raise HTTPException(400, "No email returned — did you grant the email scope?") - # Gate: only provision if this person actually contributed (the webhook - # stored them as a pending member). Prevents free memberships via /join. - if not is_pending_member(slug): - logger.warning("Rejected non-member %s (slug=%s not in pending list)", email, slug) + # Gate: only provision if this person is an active financial contributor. + # We check the LIVE Open Collective membership list (authoritative), not + # the webhook's pending table (which only fires on new events). + if not await is_active_member(slug): + logger.warning("Rejected non-member %s (slug=%s not an active contributor)", email, slug) html = """