From cc39f5ae79c24b9954a9739a07fd56bced1181e4 Mon Sep 17 00:00:00 2001 From: inference-bot Date: Fri, 11 Sep 2026 14:17:08 -0600 Subject: [PATCH] Remove OAuth flow (join/oauth endpoints, pending_members, dead helpers); provision via webhook + own email --- app/main.py | 269 ++-------------------------------------------------- 1 file changed, 6 insertions(+), 263 deletions(-) diff --git a/app/main.py b/app/main.py index 3e16b4e..e4b9849 100644 --- a/app/main.py +++ b/app/main.py @@ -56,15 +56,6 @@ OWUI_JWT_SECRET = os.environ.get("OWUI_JWT_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" OC_COLLECTIVE_SLUG = os.environ.get("OC_COLLECTIVE_SLUG", "inference-cooperative") @@ -138,13 +129,6 @@ 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'))" - ")" - ) # Migration: add slug column if the members table predates it. cols = [r[1] for r in conn.execute("PRAGMA table_info(members)").fetchall()] if "slug" not in cols: @@ -152,81 +136,6 @@ def get_db() -> sqlite3.Connection: 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 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 - - -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 { email } } } } }' - % OC_COLLECTIVE_SLUG - ) - async with httpx.AsyncClient() as client: - r = await client.post( - OC_GRAPHQL_URL, - headers={"Personal-Token": OC_PERSONAL_TOKEN}, - json={"query": query}, - ) - if r.status_code != 200: - 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 {} - 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 fetch_members() -> list[dict]: """Return the full active-member list: email, name, slug, role. @@ -380,11 +289,8 @@ def welcome_email(name: str, setup_url: str) -> tuple[str, str, str]: async def get_active_member_emails() -> list[str]: """Return the list of active member emails from the portal's own database. - We use the local `members` table (populated during OAuth, which captures the - member's email) rather than Open Collective's GraphQL, because OC returns - `email: null` for privacy — the email field is only exposed via OAuth with - the user's consent. The local table is the authoritative source of member - emails. + The local `members` table is the authoritative source of member emails + (populated during provisioning). Used for the Loomio sync. """ conn = get_db() rows = conn.execute( @@ -541,17 +447,6 @@ async def cloudron_set_active(user_id: str, active: bool) -> None: r.raise_for_status() -async def cloudron_send_invite(user_id: str, email: str) -> None: - """Send the account-setup invite email to the member.""" - async with httpx.AsyncClient() as client: - r = await client.post( - f"{CLOUDRON_API}/api/v1/users/{user_id}/send_invite_email", - headers=cloudron_headers(), - json={"email": email}, - ) - r.raise_for_status() - - async def cloudron_get_invite_link(user_id: str) -> str: """Return the account-setup link WITHOUT sending Cloudron's own email. @@ -674,8 +569,7 @@ async def opencollective_webhook(request: Request, token: str): data = payload.get("data", {}) # 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. + # privacy). We get name + slug, then look up the email via the admin token. # # Slug/name live in different places depending on the event type: # - order.processed / transaction.created → data.fromCollective.{slug,name} @@ -819,157 +713,6 @@ async def inject_key(request: Request, path: str): ) -# --- 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.

-

Contributed as a guest? Create an Open Collective account using the same email you used to contribute, then return here and connect it.

-
- -""" - 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 + slug (slug lets us map webhook events back) - r = await client.post( - OC_GRAPHQL_URL, - headers={"Authorization": f"Bearer {token}"}, - json={"query": "{ me { id name email slug } }"}, - ) - r.raise_for_status() - me = r.json().get("data", {}).get("me", {}) - email = me.get("email", "") - name = me.get("name", "Member") - slug = me.get("slug", "") - - if not email: - raise HTTPException(400, "No email returned — did you grant the email scope?") - - # 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). 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 = """ - - -Not a member yet — Inference Cooperative - - - -
-

Not a member yet

-

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.

-

Contributed as a guest? Make sure you've created an Open Collective account with the same email you used to contribute, then try again.

-

Questions? Contact info@inference.coop.

-
- -""" - return Response(content=html, media_type="text/html", status_code=403) - - # Provision the member (sends our own welcome email with the setup link). - await provision_member(email, name, slug) - - 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") @@ -997,9 +740,9 @@ async def admin_sync_loomio(token: str): async def admin_provision(token: str, request: Request): """Manually provision a member by email (full pipeline). - Protected by the same secret token as the webhook. Runs the same - provisioning as the OAuth callback: Cloudron user + members group + LiteLLM - key + invite email + Loomio sync. Body: {"email": "...", "name": "..."}. + Protected by the same secret token as the webhook. Runs the full + provisioning pipeline: Cloudron user + members group + LiteLLM key + our + welcome email + Loomio sync. Body: {"email": "...", "name": "..."}. Idempotent — safe to call repeatedly. """ if not WEBHOOK_TOKEN or not secrets.compare_digest(token, WEBHOOK_TOKEN):