Gate membership on email (via admin personal token) instead of slug — fixes guest contributors

This commit is contained in:
inference-bot committed 2026-09-10 11:52:50 -06:00
1 parent f55008f46c
commit b87a057354
1 file changed
+49 -16
+49 -16
View File
@@ -65,6 +65,13 @@ OC_TOKEN_URL = "https://opencollective.com/oauth/token"
OC_GRAPHQL_URL = "https://opencollective.com/api/graphql/v2" OC_GRAPHQL_URL = "https://opencollective.com/api/graphql/v2"
OC_COLLECTIVE_SLUG = os.environ.get("OC_COLLECTIVE_SLUG", "inference-cooperative") OC_COLLECTIVE_SLUG = os.environ.get("OC_COLLECTIVE_SLUG", "inference-cooperative")
# Personal token for the "Inference Co-op Bot" account, which is an admin of
# the collective. Authenticated as an admin, the GraphQL API exposes member
# emails (which are hidden from anonymous access). We use this to match a
# member by email — the stable identifier that works even for guest
# contributors (who have no Open Collective account and thus no usable slug).
OC_PERSONAL_TOKEN = os.environ.get("OC_PERSONAL_TOKEN", "")
# Monthly credit budget (in USD of tokens) for all members. # Monthly credit budget (in USD of tokens) for all members.
# Single sliding-scale tier: everyone gets the same $15/month in credits, # Single sliding-scale tier: everyone gets the same $15/month in credits,
# regardless of their $10/15/20 contribution. Governance decision (Loomio). # regardless of their $10/15/20 contribution. Governance decision (Loomio).
@@ -156,29 +163,54 @@ def is_pending_member(slug: str) -> bool:
return row is not None return row is not None
async def is_active_member(slug: str) -> bool: async def fetch_member_emails() -> dict[str, str]:
"""Check the LIVE Open Collective membership list (authoritative source of """Return a map of email -> role for active financial contributors.
truth) for whether this slug is a financial contributor (BACKER/ADMIN),
not just the fiscal host. More reliable than the webhook, which only fires Uses the bot's personal token (admin) so the GraphQL API exposes member
on new events and can miss existing members.""" emails, which are hidden from anonymous access. This is the authoritative
if not slug: source of truth for "who is a member" — and it works for guest
return False contributors (who have no OC account) because their email is still in the
list.
"""
if not OC_PERSONAL_TOKEN:
logger.warning("OC_PERSONAL_TOKEN not set; cannot fetch member emails")
return {}
query = ( query = (
'{ collective(slug: "%s") { members(limit: 100) { nodes { role account { slug } } } } }' '{ collective(slug: "%s") { members(limit: 100) { nodes { role account { email } } } } }'
% OC_COLLECTIVE_SLUG % OC_COLLECTIVE_SLUG
) )
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
r = await client.post(OC_GRAPHQL_URL, json={"query": query}) r = await client.post(
OC_GRAPHQL_URL,
headers={"Personal-Token": OC_PERSONAL_TOKEN},
json={"query": query},
)
if r.status_code != 200: if r.status_code != 200:
logger.warning("OC membership check failed: %s", r.status_code) logger.warning("OC member fetch failed: %s", r.status_code)
return False return {}
nodes = r.json().get("data", {}).get("collective", {}).get("members", {}).get("nodes", []) nodes = r.json().get("data", {}).get("collective", {}).get("members", {}).get("nodes", [])
result: dict[str, str] = {}
for n in nodes: for n in nodes:
role = n.get("role", "") role = n.get("role", "")
acct = n.get("account", {}) or {} acct = n.get("account", {}) or {}
if acct.get("slug") == slug and role in ("BACKER", "ADMIN"): email = (acct.get("email") or "").strip().lower()
return True if email and role in ("BACKER", "ADMIN"):
result[email] = role
return result
async def is_active_member(email: str) -> bool:
"""Check whether this email is an active financial contributor.
Matches on email (not slug) so guest contributors — who have no OC account
and thus no usable slug — are still recognized. The email comes from the
OAuth `me` query (with the user's consent); we match it against the
admin-visible member list.
"""
if not email:
return False return False
members = await fetch_member_emails()
return email.strip().lower() in members
async def get_active_member_emails() -> list[str]: async def get_active_member_emails() -> list[str]:
@@ -667,9 +699,10 @@ async def oauth_callback(request: Request):
# Gate: only provision if this person is an active financial contributor. # Gate: only provision if this person is an active financial contributor.
# We check the LIVE Open Collective membership list (authoritative), not # We check the LIVE Open Collective membership list (authoritative), not
# the webhook's pending table (which only fires on new events). # the webhook's pending table (which only fires on new events). Matching is
if not await is_active_member(slug): # by email so guest contributors (no OC account) are still recognized.
logger.warning("Rejected non-member %s (slug=%s not an active contributor)", email, slug) if not await is_active_member(email):
logger.warning("Rejected non-member %s (email not an active contributor)", email)
html = """<!DOCTYPE html> html = """<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"> <head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">