commit 5264c3e9f751a7546df4ae4880ff08d4f8a311e2 Author: inference-bot Date: Mon Sep 14 11:01:39 2026 -0600 Member dashboard: usage view + API key management (proxyAuth SSO, broker-backed) diff --git a/CloudronManifest.json b/CloudronManifest.json new file mode 100644 index 0000000..0548dab --- /dev/null +++ b/CloudronManifest.json @@ -0,0 +1,18 @@ +{ + "id": "coop.inference.member-dashboard", + "title": "Member Dashboard", + "author": "Inference Cooperative", + "description": "Member-facing dashboard: view usage, manage API keys. Reads from the member portal; holds no privileged credentials.", + "tagline": "Your Inference Cooperative account", + "version": "0.1.0", + "healthCheckPath": "/healthz", + "httpPort": 8000, + "addons": { + "localstorage": {}, + "proxyAuth": {} + }, + "manifestVersion": 2, + "website": "https://inference.coop", + "contactEmail": "info@inference.coop", + "tags": ["member", "dashboard", "cooperative"] +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0c25544 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12-slim + +WORKDIR /app/code + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ ./app/ + +RUN mkdir -p /app/data + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..3a4b782 --- /dev/null +++ b/app/main.py @@ -0,0 +1,422 @@ +"""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( + "

Not authenticated

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 """ + + + + +Inference Cooperative · Your account + + + +
+
+ +
+

Inference Cooperative

+

Your account

+
+
+ +
+

Usage

+
+ — + of your balance used +
+
+
Loading…
+
+ +
+

API keys

+

Keys let you use the co-op's AI from your own tools. They draw from the same balance as chat.

+
Loading…
+
+ + +
+
+
+
+ + + +""" + + +@app.exception_handler(HTTPException) +async def http_exception_handler(request: Request, exc: HTTPException): + if exc.status_code == 403: + return JSONResponse({"error": exc.detail}, status_code=403) + return JSONResponse({"error": exc.detail}, status_code=exc.status_code) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4c9c51d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +httpx==0.27.2 +jinja2==3.1.4