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_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.
# Single sliding-scale tier: everyone gets the same $15/month in credits,
# 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
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
async def fetch_member_emails() -> dict[str, str]:
"""Return a map of email -> role for active financial contributors.
Uses the bot's personal token (admin) so the GraphQL API exposes member
emails, which are hidden from anonymous access. This is the authoritative
source of truth for "who is a member" — and it works for guest
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 = (
'{ collective(slug: "%s") { members(limit: 100) { nodes { role account { slug } } } } }'
'{ collective(slug: "%s") { members(limit: 100) { nodes { role account { email } } } } }'
% OC_COLLECTIVE_SLUG
)
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:
logger.warning("OC membership check failed: %s", r.status_code)
return False
logger.warning("OC member fetch failed: %s", r.status_code)
return {}
nodes = r.json().get("data", {}).get("collective", {}).get("members", {}).get("nodes", [])
result: dict[str, str] = {}
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
email = (acct.get("email") or "").strip().lower()
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
members = await fetch_member_emails()
return email.strip().lower() in members
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.
# 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)
# the webhook's pending table (which only fires on new events). Matching is
# by email so guest contributors (no OC account) are still recognized.
if not await is_active_member(email):
logger.warning("Rejected non-member %s (email not an active contributor)", email)
html = """<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">