"""Member Dashboard — read-only usage view + API-key management. Security model: - Authentication is done by Cloudron's proxyAuth wall (SSO). Cloudron injects the authenticated user's identity via the X-Forwarded-User header. - This app holds NO privileged credentials. It calls the member portal's broker endpoints, authenticated with a shared BROKER_SECRET, and scopes every request to the logged-in member's email. Environment (all provided by Cloudron env vars): PORTAL_BASE — base URL of the member portal (e.g. https://portal.inference.coop) BROKER_SECRET — shared secret for the portal's broker endpoints """ import os import logging import re import httpx from fastapi import FastAPI, Request, HTTPException from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse logging.basicConfig(level=logging.INFO) logger = logging.getLogger("member-dashboard") PORTAL_BASE = os.environ.get("PORTAL_BASE", "").rstrip("/") BROKER_SECRET = os.environ.get("BROKER_SECRET", "") app = FastAPI() # --------------------------------------------------------------------------- # Identity — Cloudron proxyAuth injects the authenticated user's email. # --------------------------------------------------------------------------- def get_user_email(request: Request) -> str: """Return the logged-in user's email from Cloudron's proxyAuth headers. Cloudron's nginx auth-request module injects X-Forwarded-User. We also accept X-Remote-User and X-Auth-Request-Email as fallbacks, since the exact header set has varied across Cloudron versions. """ for header in ( "x-forwarded-user", "x-remote-user", "x-auth-request-user", "x-auth-request-email", "x-forwarded-email", ): val = request.headers.get(header) if val: val = val.strip().lower() # Some proxies prefix "user:" or use a bare username; emails are the # canonical identity here. if "@" in val: return val logger.warning("Header %s had no email (@): %r", header, val) # Last resort: Cloudron also sets x-auth-request-email. logger.warning("No identity header found; user unknown") return "" def is_trusted(request: Request) -> bool: """Only trust identity headers from Cloudron's proxy (behind us).""" # Cloudron injects these headers itself; we trust them because the app is # only reachable through Cloudron's proxy. This is a defense-in-depth note; # the app is not directly exposed. return True # --------------------------------------------------------------------------- # Broker calls (to the member portal) # --------------------------------------------------------------------------- async def broker_get(email: str, path: str) -> dict: headers = {"X-Broker-Secret": BROKER_SECRET, "X-Member-Email": email} async with httpx.AsyncClient(timeout=15.0) as client: r = await client.get(f"{PORTAL_BASE}{path}", headers=headers) if r.status_code == 401: raise HTTPException(500, "Portal rejected broker credentials") if r.status_code == 403: raise HTTPException(403, "Not an active member") if r.status_code != 200: raise HTTPException(502, f"Portal error {r.status_code}") return r.json() async def broker_post(email: str, path: str, payload: dict | None = None) -> dict: headers = {"X-Broker-Secret": BROKER_SECRET, "X-Member-Email": email} async with httpx.AsyncClient(timeout=15.0) as client: r = await client.post(f"{PORTAL_BASE}{path}", headers=headers, json=payload or {}) if r.status_code in (401, 403): raise HTTPException(r.status_code, r.text) if r.status_code not in (200, 201): raise HTTPException(502, f"Portal error {r.status_code}: {r.text}") return r.json() async def broker_delete(email: str, path: str) -> dict: headers = {"X-Broker-Secret": BROKER_SECRET, "X-Member-Email": email} async with httpx.AsyncClient(timeout=15.0) as client: r = await client.delete(f"{PORTAL_BASE}{path}", headers=headers) if r.status_code in (401, 403): raise HTTPException(r.status_code, r.text) if r.status_code not in (200, 204): raise HTTPException(502, f"Portal error {r.status_code}") return r.json() if r.content else {} # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- @app.get("/healthz") async def healthz(): return {"status": "ok"} @app.get("/") async def index(request: Request): email = get_user_email(request) if not email: return HTMLResponse( "
Please log in via the dashboard login.
", status_code=401, ) return HTMLResponse(render_page(email)) @app.get("/api/usage") async def api_usage(request: Request): email = get_user_email(request) if not email: return JSONResponse({"error": "unauthenticated"}, status_code=401) try: data = await broker_get(email, "/broker/usage") keys = await broker_get(email, "/broker/keys") data["keys"] = keys.get("keys", []) return data except HTTPException as e: return JSONResponse({"error": e.detail}, status_code=e.status_code) @app.post("/api/keys") async def api_create_key(request: Request): email = get_user_email(request) if not email: return JSONResponse({"error": "unauthenticated"}, status_code=401) body = await request.json() name = (body.get("name") or "").strip() if not re.fullmatch(r"[A-Za-z0-9._-]{1,64}", name): return JSONResponse({"error": "Invalid key name"}, status_code=400) try: return await broker_post(email, "/broker/keys", {"name": name}) except HTTPException as e: return JSONResponse({"error": e.detail}, status_code=e.status_code) @app.delete("/api/keys/{name}") async def api_revoke_key(name: str, request: Request): email = get_user_email(request) if not email: return JSONResponse({"error": "unauthenticated"}, status_code=401) try: return await broker_delete(email, f"/broker/keys/{name}") except HTTPException as e: return JSONResponse({"error": e.detail}, status_code=e.status_code) # --------------------------------------------------------------------------- # UI (brand-matched, self-contained, no external requests) # --------------------------------------------------------------------------- def render_page(email: str) -> str: return """Your account
Keys let you use the co-op's AI from your own tools. They draw from the same balance as chat.