Add SQLite store for email→key mapping; fix key/list (returns token strings); flat 5 budget
This commit is contained in:
1 parent
c1efd39c5c
commit
ee265ee18f
2 files changed
+77
-42
No files matched your search
@@ -82,7 +82,10 @@ points. Remaining work before production:
|
|||||||
→ `order.processed` (every payment), `new member` (first only), `firstPayment` flag
|
→ `order.processed` (every payment), `new member` (first only), `firstPayment` flag
|
||||||
- [x] Verify Cloudron user-creation API payload (role/group assignment)
|
- [x] Verify Cloudron user-creation API payload (role/group assignment)
|
||||||
→ `POST /api/v1/users` with `{username, email, displayName, role, active}`
|
→ `POST /api/v1/users` with `{username, email, displayName, role, active}`
|
||||||
- [ ] Add a persistent store (SQLite/Postgres) for email→key mapping
|
→ group assignment via `PUT /api/v1/users/:userId/groups` with `{groupIds: [...]}`
|
||||||
- [ ] Add HMAC signature verification for the OC webhook
|
- [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: ["<token>", ...]}`
|
||||||
|
- [ ] Add HMAC signature verification for the OC webhook (if OC supports it)
|
||||||
- [ ] Wire the OIDC addon for admin access
|
- [ ] Wire the OIDC addon for admin access
|
||||||
- [ ] Verify LiteLLM key/generate + key/list payload shapes against live gateway
|
|
||||||
+71
-39
@@ -16,6 +16,7 @@ Three responsibilities:
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI, Request, Response, HTTPException
|
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", "")
|
LITELLM_MASTER_KEY = os.environ.get("LITELLM_MASTER_KEY", "")
|
||||||
OPENCOLLECTIVE_SECRET = os.environ.get("OPENCOLLECTIVE_WEBHOOK_SECRET", "")
|
OPENCOLLECTIVE_SECRET = os.environ.get("OPENCOLLECTIVE_WEBHOOK_SECRET", "")
|
||||||
|
|
||||||
# Tier → monthly budget (in USD of tokens). Governance decision, set in Loomio.
|
# Monthly credit budget (in USD of tokens) for all members.
|
||||||
TIER_BUDGETS = {
|
# Single sliding-scale tier: everyone gets the same $15/month in credits,
|
||||||
"free": 2.0,
|
# regardless of their $10/15/20 contribution. Governance decision (Loomio).
|
||||||
"member": 15.0,
|
MEMBER_BUDGET = float(os.environ.get("MEMBER_BUDGET", "15.0"))
|
||||||
"supporter": 30.0,
|
|
||||||
}
|
|
||||||
DEFAULT_TIER = "member"
|
|
||||||
|
|
||||||
# The "members" group in Cloudron (group-based access control).
|
# The "members" group in Cloudron (group-based access control).
|
||||||
# Members are assigned to this group, which grants access to the chat app.
|
# Members are assigned to this group, which grants access to the chat app.
|
||||||
MEMBERS_GROUP_ID = os.environ.get("MEMBERS_GROUP_ID", "")
|
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 ---
|
# --- Helpers ---
|
||||||
|
|
||||||
def cloudron_headers() -> dict:
|
def cloudron_headers() -> dict:
|
||||||
@@ -128,23 +178,16 @@ async def litellm_create_key(email: str, budget: float) -> str:
|
|||||||
return r.json().get("key", "")
|
return r.json().get("key", "")
|
||||||
|
|
||||||
|
|
||||||
async def litellm_disable_key(email: str) -> None:
|
async def litellm_disable_key(key_token: str) -> None:
|
||||||
"""Disable a member's key (on payment lapse)."""
|
"""Delete a member's LiteLLM key (on payment lapse)."""
|
||||||
|
if not key_token:
|
||||||
|
return
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
# Find the key by alias
|
await client.post(
|
||||||
r = await client.get(
|
f"{LITELLM_BASE}/key/delete",
|
||||||
f"{LITELLM_BASE}/key/list",
|
|
||||||
headers={"Authorization": f"Bearer {LITELLM_MASTER_KEY}"},
|
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 ---
|
# --- A. Open Collective webhook ---
|
||||||
@@ -180,17 +223,17 @@ async def opencollective_webhook(request: Request):
|
|||||||
# - "collective.transaction.created" is DEPRECATED (being removed)
|
# - "collective.transaction.created" is DEPRECATED (being removed)
|
||||||
if event_type in ("order.processed", "new.member", "collective.member.created"):
|
if event_type in ("order.processed", "new.member", "collective.member.created"):
|
||||||
# New or renewed member → ensure active
|
# 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)
|
user_id = await cloudron_create_user(email, name)
|
||||||
await cloudron_set_group(user_id)
|
await cloudron_set_group(user_id)
|
||||||
await cloudron_set_active(user_id, True)
|
await cloudron_set_active(user_id, True)
|
||||||
await litellm_create_key(email, budget)
|
key_token = await litellm_create_key(email, MEMBER_BUDGET)
|
||||||
return JSONResponse({"status": "activated", "user_id": user_id, "budget": 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"):
|
if event_type in ("collective.member.deleted", "collective.transaction.deleted"):
|
||||||
# Lapsed member → deactivate
|
# 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": "deactivated"})
|
||||||
|
|
||||||
return JSONResponse({"status": "ignored", "type": event_type})
|
return JSONResponse({"status": "ignored", "type": event_type})
|
||||||
@@ -205,19 +248,8 @@ async def inject_key(request: Request, path: str):
|
|||||||
if not email:
|
if not email:
|
||||||
raise HTTPException(401, "No member identity (x-user-email header)")
|
raise HTTPException(401, "No member identity (x-user-email header)")
|
||||||
|
|
||||||
# Look up the member's key (in production: from a store keyed by email)
|
# Look up the member's key from the persistent store
|
||||||
# For MVP: derive deterministically or look up via LiteLLM
|
member_key = get_member_key(email)
|
||||||
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
|
|
||||||
|
|
||||||
if not member_key:
|
if not member_key:
|
||||||
raise HTTPException(403, "No active membership key")
|
raise HTTPException(403, "No active membership key")
|
||||||
|
|||||||
Reference in new issue
Block a user