""" Member Portal — membership middleware for the Inference Cooperative. Reconciles three systems: 1. Open Collective — who is a paying member (billing) 2. Cloudron — who can log in (identity/SSO) 3. LiteLLM — who can use the models, and how much (inference) Three responsibilities: A. Webhook handler — listen for Open Collective membership events B. Key injector — read the member's email from a header, inject their LiteLLM key, and forward the request to the gateway C. Admin endpoints — health, status, manual reconciliation """ import os import json import logging import sqlite3 import secrets import httpx from fastapi import FastAPI, Request, Response, HTTPException from fastapi.responses import JSONResponse logging.basicConfig(level=logging.INFO) logger = logging.getLogger("member-portal") app = FastAPI(title="Inference Cooperative Member Portal") # --- Configuration (from environment) --- CLOUDRON_API = os.environ.get("CLOUDRON_API_ORIGIN", "https://my.inference.coop") CLOUDRON_TOKEN = os.environ.get("CLOUDRON_TOKEN", "") LITELLM_BASE = os.environ.get("LITELLM_BASE", "https://gateway.inference.coop") LITELLM_MASTER_KEY = os.environ.get("LITELLM_MASTER_KEY", "") OPENCOLLECTIVE_SECRET = os.environ.get("OPENCOLLECTIVE_WEBHOOK_SECRET", "") # Shared secret that LibreChat sends as a header on every request, so the # portal can verify the request genuinely came through LibreChat (which is # behind Cloudron SSO) rather than a direct, spoofed request. PORTAL_SECRET = os.environ.get("PORTAL_SECRET", "") # Secret token required in the Open Collective webhook URL path. Open # Collective's generic webhooks are not HMAC-signed, so a secret in the URL # 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). MEMBER_BUDGET = float(os.environ.get("MEMBER_BUDGET", "15.0")) # The "members" group in Cloudron (group-based access control). # Members are assigned to this group, which grants access to the chat app. MEMBERS_GROUP_ID = os.environ.get("MEMBERS_GROUP_ID", "") # The "inactive" group in Cloudron. Lapsed members are moved here (instead of # being deleted) so they lose access but can reclaim their data on reactivation. INACTIVE_GROUP_ID = os.environ.get("INACTIVE_GROUP_ID", "") # Persistent store (SQLite) for email → LiteLLM key token mapping. # Lives in /app/data (Cloudron localstorage addon persists this). DB_PATH = os.environ.get("DB_PATH", "/app/data/members.db") def _verify_portal_secret(request: Request) -> None: """Reject requests that didn't come through LibreChat (shared secret).""" if not PORTAL_SECRET: # If no secret is configured, refuse to inject keys (fail closed). raise HTTPException(503, "Portal secret not configured") provided = request.headers.get("x-portal-secret", "") if not secrets.compare_digest(provided, PORTAL_SECRET): raise HTTPException(401, "Invalid portal secret") def get_db() -> sqlite3.Connection: conn = sqlite3.connect(DB_PATH) conn.execute( "CREATE TABLE IF NOT EXISTS members (" "email TEXT PRIMARY KEY, " "key_token TEXT, " "cloudron_user_id TEXT, " "slug TEXT, " "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: conn.execute("ALTER TABLE members ADD COLUMN slug TEXT") 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, slug: str = "") -> None: conn = get_db() conn.execute( "INSERT INTO members (email, key_token, cloudron_user_id, slug, active) " "VALUES (?, ?, ?, ?, 1) " "ON CONFLICT(email) DO UPDATE SET key_token=excluded.key_token, " "cloudron_user_id=excluded.cloudron_user_id, slug=excluded.slug, active=1", (email, key_token, cloudron_user_id, slug), ) conn.commit() conn.close() def get_member_key(email: str) -> str | None: conn = get_db() row = conn.execute( "SELECT key_token FROM members WHERE email = ? AND active = 1", (email,) ).fetchone() conn.close() return row[0] if row else None def get_member_by_slug(slug: str) -> dict | None: """Look up a member (email, key_token, cloudron_user_id) by their OC slug.""" conn = get_db() row = conn.execute( "SELECT email, key_token, cloudron_user_id FROM members WHERE slug = ?", (slug,) ).fetchone() conn.close() if not row: return None return {"email": row[0], "key_token": row[1], "cloudron_user_id": row[2]} def deactivate_member(email: str) -> str | None: """Mark a member inactive and return their key token (for deletion).""" conn = get_db() row = conn.execute( "SELECT key_token FROM members WHERE email = ?", (email,) ).fetchone() conn.execute("UPDATE members SET active = 0 WHERE email = ?", (email,)) conn.commit() conn.close() return row[0] if row else None # --- Helpers --- def cloudron_headers() -> dict: return {"Authorization": f"Bearer {CLOUDRON_TOKEN}"} async def cloudron_create_user(email: str, name: str) -> str: """Create (or return existing) Cloudron user, assigned to the members group. Verified user shape (from live API): {id, username, email, fallbackEmail, displayName, role, active, groupIds} Roles: "owner", "admin", "user". Group assignment is a SEPARATE call: PUT /api/v1/users/:userId/groups with body {"groupIds": [...]}. """ async with httpx.AsyncClient() as client: # Check if user exists r = await client.get( f"{CLOUDRON_API}/api/v1/users", headers=cloudron_headers(), ) r.raise_for_status() for u in r.json().get("users", []): if u.get("email") == email: return u["id"] # Create user (role "user" = regular member). Only email is required; # Cloudron lets the user choose their own username during account setup. r = await client.post( f"{CLOUDRON_API}/api/v1/users", headers=cloudron_headers(), json={ "email": email, "displayName": name, "role": "user", "active": True, }, ) r.raise_for_status() return r.json()["id"] async def cloudron_set_group(user_id: str, group_id: str | None = None) -> None: """Assign a user to a group (defaults to the members group).""" gid = group_id or MEMBERS_GROUP_ID if not gid: logger.warning("No group id; skipping group assignment") return async with httpx.AsyncClient() as client: r = await client.put( f"{CLOUDRON_API}/api/v1/users/{user_id}/groups", headers=cloudron_headers(), json={"groupIds": [gid]}, ) r.raise_for_status() async def cloudron_set_active(user_id: str, active: bool) -> None: async with httpx.AsyncClient() as client: r = await client.put( f"{CLOUDRON_API}/api/v1/users/{user_id}/active", headers=cloudron_headers(), json={"active": active}, ) 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 litellm_find_key_by_alias(alias: str) -> str | None: """Find an existing key's token by its alias (key/list returns tokens).""" async with httpx.AsyncClient() as client: r = await client.get( f"{LITELLM_BASE}/key/list", headers={"Authorization": f"Bearer {LITELLM_MASTER_KEY}"}, ) r.raise_for_status() tokens = r.json().get("keys", []) for token in tokens: info = await client.get( f"{LITELLM_BASE}/key/info", headers={"Authorization": f"Bearer {LITELLM_MASTER_KEY}"}, params={"key": token}, ) if info.status_code == 200: data = info.json().get("info", {}) if data.get("key_alias") == alias: return token return None async def litellm_create_key(email: str, budget: float) -> str: """Create a LiteLLM virtual key for a member with a budget cap. Idempotent: if a key with this alias already exists, return its token. """ alias = f"member:{email}" # If the key already exists (e.g. from a prior partial attempt), reuse it. existing = await litellm_find_key_by_alias(alias) if existing: logger.info("Reusing existing LiteLLM key for %s", email) return existing async with httpx.AsyncClient() as client: r = await client.post( f"{LITELLM_BASE}/key/generate", headers={"Authorization": f"Bearer {LITELLM_MASTER_KEY}"}, json={ "key_alias": alias, "max_budget": budget, "budget_duration": "30d", "models": ["deepseek-v4-flash", "gpt-oss-120b"], }, ) r.raise_for_status() return r.json().get("key", "") async def litellm_disable_key(key_token: str) -> None: """Delete a member's LiteLLM key (on payment lapse).""" if not key_token: return async with httpx.AsyncClient() as client: await client.post( f"{LITELLM_BASE}/key/delete", headers={"Authorization": f"Bearer {LITELLM_MASTER_KEY}"}, json={"keys": [key_token]}, ) # --- A. Open Collective webhook --- @app.post("/webhook/opencollective/{token}") async def opencollective_webhook(request: Request, token: str): """Handle Open Collective membership events. Authenticated by a secret token in the URL path (Open Collective's generic webhooks are not HMAC-signed, so a secret URL is the standard way to authenticate them). """ if not WEBHOOK_TOKEN or not secrets.compare_digest(token, WEBHOOK_TOKEN): raise HTTPException(401, "Invalid webhook token") payload = await request.json() event_type = payload.get("type", "") data = payload.get("data", {}) member = data.get("member", {}) or data.get("fromCollective", {}) # 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", "") 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) # - "new member" → fires on FIRST contribution only # - 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 → 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 → move to the inactive group (lose access, keep data). # The webhook gives us the slug; we map it to the member's email/user. if slug: member = get_member_by_slug(slug) if member: # Move to inactive group (revokes chat access, keeps account + data) if INACTIVE_GROUP_ID and member.get("cloudron_user_id"): await cloudron_set_group(member["cloudron_user_id"], INACTIVE_GROUP_ID) # Mark inactive in our DB (key injector will refuse requests) deactivate_member(member["email"]) logger.info("Deactivated member %s (slug=%s)", member["email"], slug) return JSONResponse({"status": "deactivated", "email": member["email"]}) return JSONResponse({"status": "deactivated", "note": "no matching member"}) return JSONResponse({"status": "ignored", "type": event_type}) # --- B. Key injector (proxy) --- @app.get("/v1/models") async def list_models(): """List models (same for everyone — no per-user auth needed).""" async with httpx.AsyncClient() as client: r = await client.get( f"{LITELLM_BASE}/v1/models", headers={"Authorization": f"Bearer {LITELLM_MASTER_KEY}"}, ) return Response( content=r.content, status_code=r.status_code, headers={"content-type": r.headers.get("content-type", "application/json")}, ) @app.api_route("/v1/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) async def inject_key(request: Request, path: str): """Read the member's email from a header, inject their key, forward to LiteLLM.""" # Verify the request came through LibreChat (shared secret), so a direct # caller can't spoof the X-User-Email header and use another member's key. _verify_portal_secret(request) email = request.headers.get("x-user-email", "") if not email: raise HTTPException(401, "No member identity (x-user-email header)") # Look up the member's key from the persistent store member_key = get_member_key(email) if not member_key: raise HTTPException(403, "No active membership key") # Forward the request to LiteLLM with the member's key body = await request.body() headers = dict(request.headers) headers["authorization"] = f"Bearer {member_key}" headers.pop("host", None) headers.pop("content-length", None) async with httpx.AsyncClient() as client: upstream = await client.request( method=request.method, url=f"{LITELLM_BASE}/v1/{path}", headers=headers, content=body, ) return Response( content=upstream.content, status_code=upstream.status_code, headers={"content-type": upstream.headers.get("content-type", "application/json")}, ) # --- 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 + 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?") # 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, slug) await cloudron_send_invite(user_id, email) 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(): return {"status": "ok"} @app.get("/") async def index(): return {"service": "Inference Cooperative Member Portal", "version": "0.1.0"}