Gate OAuth on pending membership (payment check) + fix slug extraction for member.created events

This commit is contained in:
inference-bot committed 2026-09-05 22:45:35 -06:00
1 parent e291406b0b
commit b58a975518
1 file changed
+50 -3
+50 -3
View File
@@ -120,6 +120,20 @@ def store_pending_member(slug: str, name: str) -> None:
conn.close() conn.close()
def is_pending_member(slug: str) -> bool:
"""True if this slug has a pending membership (i.e. the webhook saw a
contribution from them). Used to gate the OAuth flow so only paying
members can provision an account."""
if not slug:
return False
conn = get_db()
row = conn.execute(
"SELECT 1 FROM pending_members WHERE slug = ?", (slug,)
).fetchone()
conn.close()
return row is not None
def store_member(email: str, key_token: str, cloudron_user_id: str, slug: str = "") -> None: def store_member(email: str, key_token: str, cloudron_user_id: str, slug: str = "") -> None:
conn = get_db() conn = get_db()
conn.execute( conn.execute(
@@ -323,12 +337,18 @@ async def opencollective_webhook(request: Request, token: str):
event_type = payload.get("type", "") event_type = payload.get("type", "")
data = payload.get("data", {}) data = payload.get("data", {})
member = data.get("member", {}) or data.get("fromCollective", {})
# The webhook does NOT include email (Open Collective strips it for # The webhook does NOT include email (Open Collective strips it for
# privacy). We get name + slug, store a pending member, and obtain the # privacy). We get name + slug, store a pending member, and obtain the
# email later via the OAuth "connect your account" flow. # email later via the OAuth "connect your account" flow.
name = member.get("name", "Member") #
slug = member.get("slug", "") # Slug/name live in different places depending on the event type:
# - order.processed / transaction.created → data.fromCollective.{slug,name}
# - collective.member.created → data.member.memberCollective.{slug,name}
from_collective = data.get("fromCollective", {})
member_collective = (data.get("member", {}) or {}).get("memberCollective", {})
slug = from_collective.get("slug") or member_collective.get("slug", "")
name = from_collective.get("name") or member_collective.get("name", "Member")
logger.info("Open Collective event: %s (name=%s, slug=%s)", event_type, name, slug) logger.info("Open Collective event: %s (name=%s, slug=%s)", event_type, name, slug)
@@ -510,6 +530,33 @@ async def oauth_callback(request: Request):
if not email: if not email:
raise HTTPException(400, "No email returned — did you grant the email scope?") 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)
html = """<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Not a member yet — Inference Cooperative</title>
<style>
body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; background: #faf8f5; color: #2d3327; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; padding: 24px; }
.card { background: #fff; border: 1px solid #e8e2d8; border-radius: 12px; padding: 40px; max-width: 480px; text-align: center; }
h1 { font-size: 24px; margin: 0 0 12px; }
p { color: #6b7a62; line-height: 1.5; margin: 0 0 20px; }
.note { font-size: 13px; color: #94a08c; margin-top: 20px; }
.note a { color: #6b7a62; }
</style>
</head>
<body>
<div class="card">
<h1>Not a member yet</h1>
<p>We couldn't find an active membership for your account. To join, contribute on our Open Collective page first, then return here to finish setup.</p>
<p class="note">Questions? Contact <a href="mailto:info@inference.coop">info@inference.coop</a>.</p>
</div>
</body>
</html>"""
return Response(content=html, media_type="text/html", status_code=403)
# Provision the member # Provision the member
user_id = await cloudron_create_user(email, name) user_id = await cloudron_create_user(email, name)
await cloudron_set_group(user_id) await cloudron_set_group(user_id)