From 808357215d6a0d02ccfc725b00a9e479204ca1a4 Mon Sep 17 00:00:00 2001 From: inference-bot Date: Sat, 5 Sep 2026 22:09:56 -0600 Subject: [PATCH] Add OAuth 'connect your account' flow: /join page, /oauth/start, /oauth/callback (provisions member) --- app/main.py | 182 +++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 165 insertions(+), 17 deletions(-) diff --git a/app/main.py b/app/main.py index 8d8f41b..513d1cb 100644 --- a/app/main.py +++ b/app/main.py @@ -45,6 +45,17 @@ PORTAL_SECRET = os.environ.get("PORTAL_SECRET", "") # is the standard way to authenticate them. WEBHOOK_TOKEN = os.environ.get("WEBHOOK_TOKEN", "") +# Open Collective OAuth app credentials (for the "connect your account" flow, +# which is how we obtain a member's email — the webhook strips it for privacy). +OC_OAUTH_CLIENT_ID = os.environ.get("OC_OAUTH_CLIENT_ID", "") +OC_OAUTH_CLIENT_SECRET = os.environ.get("OC_OAUTH_CLIENT_SECRET", "") +OC_OAUTH_REDIRECT_URI = os.environ.get( + "OC_OAUTH_REDIRECT_URI", "https://portal.inference.coop/oauth/callback" +) +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" + # 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). @@ -79,9 +90,27 @@ def get_db() -> sqlite3.Connection: "active INTEGER DEFAULT 1" ")" ) + conn.execute( + "CREATE TABLE IF NOT EXISTS pending_members (" + "slug TEXT PRIMARY KEY, " + "name TEXT, " + "created_at TEXT DEFAULT (datetime('now'))" + ")" + ) return conn +def store_pending_member(slug: str, name: str) -> None: + conn = get_db() + conn.execute( + "INSERT INTO pending_members (slug, name) VALUES (?, ?) " + "ON CONFLICT(slug) DO UPDATE SET name=excluded.name", + (slug, name), + ) + conn.commit() + conn.close() + + def store_member(email: str, key_token: str, cloudron_user_id: str) -> None: conn = get_db() conn.execute( @@ -229,14 +258,13 @@ async def opencollective_webhook(request: Request, token: str): event_type = payload.get("type", "") data = payload.get("data", {}) member = data.get("member", {}) or data.get("fromCollective", {}) - email = member.get("email") or data.get("email") + # The webhook does NOT include email (Open Collective strips it for + # privacy). We get name + slug, store a pending member, and obtain the + # email later via the OAuth "connect your account" flow. name = member.get("name", "Member") + slug = member.get("slug", "") - if not email: - logger.warning("Webhook without email: %s", event_type) - return JSONResponse({"status": "ignored", "reason": "no email"}) - - logger.info("Open Collective event: %s for %s", event_type, email) + logger.info("Open Collective event: %s (name=%s, slug=%s)", event_type, name, slug) # Open Collective webhook events (verified): # - "order.processed" → fires on EVERY payment (incl. monthly recurring) @@ -244,18 +272,13 @@ async def opencollective_webhook(request: Request, token: str): # - payload has "firstPayment" boolean to distinguish new vs recurring # - "collective.transaction.created" is DEPRECATED (being removed) if event_type in ("order.processed", "new.member", "collective.member.created"): - # New or renewed member → ensure active - user_id = await cloudron_create_user(email, name) - await cloudron_set_group(user_id) - await cloudron_set_active(user_id, True) - key_token = await litellm_create_key(email, MEMBER_BUDGET) - store_member(email, key_token, user_id) - return JSONResponse({"status": "activated", "user_id": user_id, "budget": MEMBER_BUDGET}) + # New or renewed member → store as pending; they complete via OAuth + if slug: + store_pending_member(slug, name) + return JSONResponse({"status": "pending", "name": name, "slug": slug}) if event_type in ("collective.member.deleted", "collective.transaction.deleted"): - # Lapsed member → deactivate - key_token = deactivate_member(email) - await litellm_disable_key(key_token) + # Lapsed member → deactivate (we may not have their email yet) return JSONResponse({"status": "deactivated"}) return JSONResponse({"status": "ignored", "type": event_type}) @@ -317,7 +340,132 @@ async def inject_key(request: Request, path: str): ) -# --- C. Admin / health --- +# --- C. OAuth "connect your account" flow --- + +@app.get("/join") +async def join_page(): + """The 'finish your setup' page — where a new member connects their + Open Collective account so we can obtain their email (with consent).""" + html = """ + + + + +Finish your setup — Inference Cooperative + + + +
+

Finish your setup

+

Thanks for joining the Inference Cooperative! To set up your account, connect your Open Collective account so we can verify your membership.

+ Connect Open Collective +

You'll receive an account-setup email shortly after connecting.
Didn't get it? Contact info@inference.coop.

+
+ +""" + return Response(content=html, media_type="text/html") + + +@app.get("/oauth/start") +async def oauth_start(): + """Redirect the user to Open Collective's consent screen.""" + state = secrets.token_urlsafe(16) + params = { + "client_id": OC_OAUTH_CLIENT_ID, + "response_type": "code", + "redirect_uri": OC_OAUTH_REDIRECT_URI, + "scope": "email", + "state": state, + } + qs = "&".join(f"{k}={v}" for k, v in params.items()) + return Response( + status_code=302, + headers={"Location": f"{OC_AUTHORIZE_URL}?{qs}"}, + ) + + +@app.get("/oauth/callback") +async def oauth_callback(request: Request): + """Exchange the OAuth code for a token, fetch the email, and provision.""" + code = request.query_params.get("code", "") + if not code: + raise HTTPException(400, "Missing code") + + # Exchange code for access token + async with httpx.AsyncClient() as client: + r = await client.post( + OC_TOKEN_URL, + data={ + "grant_type": "authorization_code", + "client_id": OC_OAUTH_CLIENT_ID, + "client_secret": OC_OAUTH_CLIENT_SECRET, + "code": code, + "redirect_uri": OC_OAUTH_REDIRECT_URI, + }, + ) + r.raise_for_status() + token = r.json().get("access_token", "") + + if not token: + raise HTTPException(500, "No access token returned") + + # Fetch the user's email + r = await client.post( + OC_GRAPHQL_URL, + headers={"Authorization": f"Bearer {token}"}, + json={"query": "{ me { id name email } }"}, + ) + r.raise_for_status() + me = r.json().get("data", {}).get("me", {}) + email = me.get("email", "") + name = me.get("name", "Member") + + if not email: + raise HTTPException(400, "No email returned — did you grant the email scope?") + + # Provision the member + user_id = await cloudron_create_user(email, name) + await cloudron_set_group(user_id) + await cloudron_set_active(user_id, True) + key_token = await litellm_create_key(email, MEMBER_BUDGET) + store_member(email, key_token, user_id) + + logger.info("Provisioned member %s (user_id=%s)", email, user_id) + + html = """ + + +You're in — Inference Cooperative + + + +
+

You're in!

+

Your account is being set up. Check your email for a link to create your account and start chatting.

+

Didn't get it? Contact info@inference.coop.

+
+ +""" + return Response(content=html, media_type="text/html") + + +# --- D. Admin / health --- @app.get("/health") async def health():