From ee265ee18f25f72d4797fd665fe500690223f92f Mon Sep 17 00:00:00 2001 From: inference-bot Date: Fri, 4 Sep 2026 17:28:46 -0600 Subject: [PATCH] =?UTF-8?q?Add=20SQLite=20store=20for=20email=E2=86=92key?= =?UTF-8?q?=20mapping;=20fix=20key/list=20(returns=20token=20strings);=20f?= =?UTF-8?q?lat=205=20budget?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 9 +++-- app/main.py | 110 +++++++++++++++++++++++++++++++++------------------- 2 files changed, 77 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index ebec893..4a2513f 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,10 @@ points. Remaining work before production: → `order.processed` (every payment), `new member` (first only), `firstPayment` flag - [x] Verify Cloudron user-creation API payload (role/group assignment) → `POST /api/v1/users` with `{username, email, displayName, role, active}` -- [ ] Add a persistent store (SQLite/Postgres) for email→key mapping -- [ ] Add HMAC signature verification for the OC webhook + → group assignment via `PUT /api/v1/users/:userId/groups` with `{groupIds: [...]}` +- [x] Add a persistent store (SQLite) for email→key mapping + → `key/list` returns token strings (not objects), so we store email→token locally +- [x] Verify LiteLLM key/generate + key/list payload shapes against live gateway + → `key/generate` returns `{key: "sk-..."}`; `key/list` returns `{keys: ["", ...]}` +- [ ] Add HMAC signature verification for the OC webhook (if OC supports it) - [ ] Wire the OIDC addon for admin access -- [ ] Verify LiteLLM key/generate + key/list payload shapes against live gateway diff --git a/app/main.py b/app/main.py index 6e498bf..5783ac5 100644 --- a/app/main.py +++ b/app/main.py @@ -16,6 +16,7 @@ Three responsibilities: import os import json import logging +import sqlite3 import httpx from fastapi import FastAPI, Request, Response, HTTPException @@ -33,18 +34,67 @@ 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", "") -# Tier → monthly budget (in USD of tokens). Governance decision, set in Loomio. -TIER_BUDGETS = { - "free": 2.0, - "member": 15.0, - "supporter": 30.0, -} -DEFAULT_TIER = "member" +# 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", "") +# 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 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, " + "active INTEGER DEFAULT 1" + ")" + ) + return conn + + +def store_member(email: str, key_token: str, cloudron_user_id: str) -> None: + conn = get_db() + conn.execute( + "INSERT INTO members (email, key_token, cloudron_user_id, active) " + "VALUES (?, ?, ?, 1) " + "ON CONFLICT(email) DO UPDATE SET key_token=excluded.key_token, " + "cloudron_user_id=excluded.cloudron_user_id, active=1", + (email, key_token, cloudron_user_id), + ) + 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 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: @@ -128,23 +178,16 @@ async def litellm_create_key(email: str, budget: float) -> str: return r.json().get("key", "") -async def litellm_disable_key(email: str) -> None: - """Disable a member's key (on payment lapse).""" +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: - # Find the key by alias - r = await client.get( - f"{LITELLM_BASE}/key/list", + await client.post( + f"{LITELLM_BASE}/key/delete", headers={"Authorization": f"Bearer {LITELLM_MASTER_KEY}"}, + json={"keys": [key_token]}, ) - r.raise_for_status() - for k in r.json().get("keys", []): - if k.get("key_alias") == f"member:{email}": - await client.post( - f"{LITELLM_BASE}/key/delete", - headers={"Authorization": f"Bearer {LITELLM_MASTER_KEY}"}, - json={"keys": [k["token"]]}, - ) - return # --- A. Open Collective webhook --- @@ -180,17 +223,17 @@ async def opencollective_webhook(request: Request): # - "collective.transaction.created" is DEPRECATED (being removed) if event_type in ("order.processed", "new.member", "collective.member.created"): # New or renewed member → ensure active - tier = (data.get("tier") or {}).get("slug", DEFAULT_TIER) - budget = TIER_BUDGETS.get(tier, TIER_BUDGETS[DEFAULT_TIER]) user_id = await cloudron_create_user(email, name) await cloudron_set_group(user_id) await cloudron_set_active(user_id, True) - await litellm_create_key(email, budget) - return JSONResponse({"status": "activated", "user_id": user_id, "budget": budget}) + 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}) if event_type in ("collective.member.deleted", "collective.transaction.deleted"): # Lapsed member → deactivate - await litellm_disable_key(email) + key_token = deactivate_member(email) + await litellm_disable_key(key_token) return JSONResponse({"status": "deactivated"}) return JSONResponse({"status": "ignored", "type": event_type}) @@ -205,19 +248,8 @@ async def inject_key(request: Request, path: str): if not email: raise HTTPException(401, "No member identity (x-user-email header)") - # Look up the member's key (in production: from a store keyed by email) - # For MVP: derive deterministically or look up via LiteLLM - 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() - member_key = None - for k in r.json().get("keys", []): - if k.get("key_alias") == f"member:{email}": - member_key = k.get("token") - break + # 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")