diff --git a/app/main.py b/app/main.py index f869879..dd8c2a3 100644 --- a/app/main.py +++ b/app/main.py @@ -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 - return False + 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 = """