Files
admin-panel/app/main.py
T

580 lines
24 KiB
Python

"""Admin Panel — co-op-wide member overview, spend, and balance management.
Security model:
- Authentication is done by Cloudron's proxyAuth wall (SSO). Cloudron injects
the authenticated user's username via X-Remote-User.
- Access is further restricted to a small ADMIN_USERNAMES allowlist (checked
against the injected username), so only designated admins see this panel.
- This app calls the member portal's /admin/overview and /admin/set-balance
endpoints, authenticated with the portal's WEBHOOK_TOKEN (ADMIN_TOKEN).
Environment (Cloudron env vars):
PORTAL_BASE — base URL of the member portal
ADMIN_TOKEN — the portal's admin/webhook token (for /admin/* endpoints)
ADMIN_USERNAMES — comma-separated list of allowed Cloudron usernames
"""
import os
import logging
import httpx
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from app import oidc
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("admin-panel")
PORTAL_BASE = os.environ.get("PORTAL_BASE", "").rstrip("/")
ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "")
ADMIN_USERNAMES = {
u.strip().lower()
for u in os.environ.get("ADMIN_USERNAMES", "").split(",")
if u.strip()
}
app = FastAPI()
def get_identity(request: Request) -> str:
"""Return the authenticated user's identity (Cloudron username).
Reads the validated OIDC session cookie. No header fallback — the app no
longer trusts a client-supplied identity header (that path existed only
during the proxyAuth → OIDC transition and is now removed).
"""
if not oidc.is_oidc_configured():
return ""
token = request.cookies.get(oidc.SESSION_COOKIE)
if not token:
return ""
return oidc.read_session(token) or ""
def is_admin(request: Request) -> bool:
identity = get_identity(request)
if not identity:
return False
return identity.lower() in ADMIN_USERNAMES
async def portal_get(path: str) -> dict:
async with httpx.AsyncClient(timeout=20.0) as client:
r = await client.get(f"{PORTAL_BASE}{path}", headers={"X-Admin-Token": ADMIN_TOKEN})
if r.status_code != 200:
raise HTTPException(502, f"Portal error {r.status_code}")
return r.json()
async def portal_post(path: str, payload: dict) -> dict:
async with httpx.AsyncClient(timeout=20.0) as client:
r = await client.post(f"{PORTAL_BASE}{path}", json=payload, headers={"X-Admin-Token": ADMIN_TOKEN})
if r.status_code not in (200, 201):
try:
detail = r.json().get("detail", r.text)
except Exception:
detail = r.text
raise HTTPException(502, f"Portal error {r.status_code}: {detail}")
return r.json()
@app.get("/healthz")
async def healthz():
return {"status": "ok"}
@app.get("/")
async def index(request: Request):
identity = get_identity(request)
# Unauthenticated + OIDC configured → send to the OIDC login.
if not identity:
if oidc.is_oidc_configured():
return RedirectResponse("/auth/openid/login", status_code=302)
return HTMLResponse(
"<h1>Forbidden</h1><p>This panel is restricted to administrators.</p>",
status_code=403,
)
# Authenticated but not an admin → forbid.
if identity.lower() not in ADMIN_USERNAMES:
return HTMLResponse(
"<h1>Forbidden</h1><p>This panel is restricted to administrators.</p>",
status_code=403,
)
return HTMLResponse(render_page())
@app.get("/api/overview")
async def api_overview(request: Request):
if not is_admin(request):
return JSONResponse({"error": "forbidden"}, status_code=403)
return await portal_get("/admin/overview")
@app.get("/api/model-usage")
async def api_model_usage(request: Request):
if not is_admin(request):
return JSONResponse({"error": "forbidden"}, status_code=403)
try:
return await portal_get("/admin/model-usage")
except HTTPException as e:
return JSONResponse({"error": e.detail}, status_code=e.status_code)
@app.post("/api/set-balance")
async def api_set_balance(request: Request):
if not is_admin(request):
return JSONResponse({"error": "forbidden"}, status_code=403)
body = await request.json()
email = (body.get("email") or "").strip().lower()
if not email:
return JSONResponse({"error": "Missing email"}, status_code=400)
payload = {"email": email}
if "add" in body:
payload["add"] = float(body["add"])
elif "balance" in body:
payload["balance"] = float(body["balance"])
else:
return JSONResponse({"error": "Provide 'balance' or 'add'"}, status_code=400)
try:
return await portal_post("/admin/set-balance", payload)
except HTTPException as e:
return JSONResponse({"error": e.detail}, status_code=e.status_code)
@app.post("/api/set-credits")
async def api_set_credits(request: Request):
if not is_admin(request):
return JSONResponse({"error": "forbidden"}, status_code=403)
body = await request.json()
email = (body.get("email") or "").strip().lower()
if not email:
return JSONResponse({"error": "Missing email"}, status_code=400)
payload = {"email": email, "reason": body.get("reason") or "admin adjustment"}
if "add" in body:
payload["add"] = float(body["add"])
elif "set" in body:
payload["set"] = float(body["set"])
else:
return JSONResponse({"error": "Provide 'set' or 'add'"}, status_code=400)
try:
return await portal_post("/admin/set-credits", payload)
except HTTPException as e:
return JSONResponse({"error": e.detail}, status_code=e.status_code)
# ---------------------------------------------------------------------------
# OIDC routes — authorization-code flow against Cloudron's OIDC provider.
# Active only when the `oidc` addon is configured (CLOUDRON_OIDC_* present).
# During the transition the legacy proxyAuth header still works as a fallback.
# ---------------------------------------------------------------------------
@app.get("/auth/openid/login")
async def oidc_login(request: Request):
if not oidc.is_oidc_configured():
raise HTTPException(404, "OIDC not configured")
login_url, state, nonce = oidc.build_login_url(request.headers.get("host", ""))
resp = RedirectResponse(login_url, status_code=302)
# Short-lived, HttpOnly, SameSite=Lax cookies to carry state/nonce.
resp.set_cookie("oidc_state", state, max_age=600, httponly=True, samesite="lax")
resp.set_cookie("oidc_nonce", nonce, max_age=600, httponly=True, samesite="lax")
return resp
@app.get("/auth/openid/callback")
async def oidc_callback(request: Request):
if not oidc.is_oidc_configured():
raise HTTPException(404, "OIDC not configured")
code = request.query_params.get("code")
state = request.query_params.get("state")
expected_state = request.cookies.get("oidc_state")
nonce = request.cookies.get("oidc_nonce")
if not code or not state or not expected_state or not nonce:
return HTMLResponse("<h1>Login failed</h1><p>Incomplete OIDC callback.</p>", status_code=400)
if state != expected_state:
return HTMLResponse("<h1>Login failed</h1><p>State mismatch (possible CSRF).</p>", status_code=400)
try:
identity = await oidc.exchange_code(code, request.headers.get("host", ""), nonce)
except ValueError as e:
logger.warning("OIDC login failed: %s", e)
return HTMLResponse("<h1>Login failed</h1><p>Could not complete sign-in.</p>", status_code=400)
session = oidc.write_session(identity)
resp = RedirectResponse("/", status_code=302)
resp.set_cookie(oidc.SESSION_COOKIE, session, max_age=oidc.SESSION_MAX_AGE, httponly=True, samesite="lax")
resp.delete_cookie("oidc_state")
resp.delete_cookie("oidc_nonce")
return resp
@app.get("/logout")
async def logout():
resp = RedirectResponse("/", status_code=302)
resp.delete_cookie(oidc.SESSION_COOKIE)
return resp
def _esc(s: str) -> str:
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
def render_page() -> str:
return PAGE_HTML
PAGE_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Inference Cooperative · Admin</title>
<style>
:root {
--cream: #faf8f5; --ink: #2d3327; --muted: #6b7a62;
--green-deep: #5b8c5a; --green-mid: #6ba86b; --border: #e4e0d8; --danger: #b3543a;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Figtree', -apple-system, 'Segoe UI', system-ui, sans-serif; background: var(--cream); color: var(--ink); line-height: 1.5; }
.wrap { max-width: 1120px; margin: 0 auto; padding: 32px 24px 48px; }
header { display: flex; align-items: center; gap: 14px; margin-bottom: 24px; }
.brand h1 { font-size: 20px; font-weight: 700; }
.brand .sub { color: var(--muted); font-size: 13px; }
.totals { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; margin-bottom: 24px; }
.tot { background: #fff; border: 1px solid var(--border); border-radius: 12px; padding: 16px 20px; }
.tot .num { font-size: 24px; font-weight: 700; color: var(--green-deep); }
.tot .lbl { font-size: 12px; color: var(--muted); }
table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
th, td { text-align: left; padding: 10px 14px; border-bottom: 1px solid #f0ede6; font-size: 13px; }
th { background: #f6f4ee; color: var(--muted); font-weight: 600; font-size: 12px; }
tr:last-child td { border-bottom: none; }
.pill { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
.pill.active { background: #eaf3ea; color: #2f5c30; }
.pill.inactive { background: #f6e9e5; color: var(--danger); }
.pill.manual { background: #f1e8da; color: #a97a4a; }
.num { font-variant-numeric: tabular-nums; }
.flash { padding: 12px 16px; border-radius: 8px; margin: 16px 0; font-size: 14px; display: none; }
.flash.show { display: block; }
.flash.ok { background: #eaf3ea; color: #2f5c30; }
.flash.err { background: #f6e9e5; color: var(--danger); }
.adjust { display: inline-flex; gap: 6px; align-items: center; white-space: nowrap; }
.adjust input { width: 56px; padding: 4px 6px; border: 1px solid var(--border); border-radius: 6px; font-size: 12px; flex-shrink: 0; }
.adjust button { cursor: pointer; border: none; border-radius: 6px; background: var(--green-deep); color: #fff; font-size: 11px; font-weight: 600; padding: 4px 8px; flex-shrink: 0; }
.adjust button:hover { background: #4e7a4d; }
.adjust .credits-btn { background: var(--bronze, #a97a4a); }
.adjust .credits-btn:hover { background: #92693c; }
td:last-child, th:last-child { white-space: nowrap; }
.tabs { display: flex; gap: 8px; margin: 28px 0 12px; align-items: center; flex-wrap: wrap; }
.tabs h2 { font-size: 17px; font-weight: 700; margin-right: 8px; }
.tab-btn { cursor: pointer; border: 1px solid var(--border); background: #fff; color: var(--muted); border-radius: 8px; font-size: 13px; font-weight: 600; padding: 6px 14px; }
.tab-btn.active { background: var(--green-deep); color: #fff; border-color: var(--green-deep); }
.range-group { display: inline-flex; gap: 6px; margin-left: auto; align-items: center; }
.range-label { font-size: 12px; color: var(--muted); margin-right: 2px; }
.range-btn { cursor: pointer; border: 1px solid var(--border); background: #fff; color: var(--muted); border-radius: 8px; font-size: 12px; font-weight: 600; padding: 5px 12px; }
.range-btn.active { background: var(--green-mid); color: #fff; border-color: var(--green-mid); }
.members-all-time .balance-col { display: none; }
.table-scroll { overflow-x: auto; border-radius: 12px; }
.table-scroll table { min-width: 980px; }
th.sortable { cursor: pointer; user-select: none; }
th.sortable:hover { color: var(--green-deep); }
th .arrow { font-size: 10px; margin-left: 3px; opacity: 0.7; }
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="brand">
<h1>Inference Cooperative</h1>
<div class="sub">Admin panel</div>
</div>
</header>
<div class="totals">
<div class="tot"><div class="num" id="tot-members">—</div><div class="lbl">members</div></div>
<div class="tot"><div class="num" id="tot-spend-cycle">—</div><div class="lbl">current balance used</div></div>
<div class="tot"><div class="num" id="tot-spend-all">—</div><div class="lbl">all-time spend</div></div>
</div>
<div class="flash" id="flash"></div>
<div class="tabs" style="margin-top:28px;">
<h2 id="view-title">Members</h2>
<button class="tab-btn active" id="tab-members" onclick="switchView('members')">Members</button>
<button class="tab-btn" id="tab-models" onclick="switchView('models')">Model usage</button>
<span class="range-group" id="member-mode-group">
<button class="range-btn active" id="mmode-balance" onclick="setMemberMode('balance')">Balance</button>
<button class="range-btn" id="mmode-all" onclick="setMemberMode('all')">All time</button>
</span>
<span class="range-group" id="range-group" style="display:none;">
<span class="range-label">Period</span>
<button class="range-btn" id="range-30" onclick="setRange(30)">Last 30 days</button>
<button class="range-btn active" id="range-all" onclick="setRange(0)">All time</button>
</span>
</div>
<div class="table-scroll">
<table id="table-members">
<thead>
<tr>
<th class="sortable" onclick="sortMembers('email')">Member<span class="arrow" id="arrow-members-email"></span></th>
<th class="sortable" onclick="sortMembers('active')">Status<span class="arrow" id="arrow-members-active"></span></th>
<th class="sortable" onclick="sortMembers('activated')">Activated<span class="arrow" id="arrow-members-activated"></span></th>
<th class="sortable" onclick="sortMembers('manual')">Source<span class="arrow" id="arrow-members-manual"></span></th>
<th class="sortable balance-col" onclick="sortMembers('balance')">Allowance<span class="arrow" id="arrow-members-balance"></span></th>
<th class="sortable balance-col" onclick="sortMembers('credit_balance')">Credits<span class="arrow" id="arrow-members-credit_balance"></span></th>
<th class="sortable" onclick="sortMembers('spend')"><span id="spend-mode-label">Spend</span><span class="arrow" id="arrow-members-spend"></span></th>
<th class="sortable balance-col" onclick="sortMembers('remaining')">Remaining<span class="arrow" id="arrow-members-remaining"></span></th>
<th class="sortable balance-col" onclick="sortMembers('reset_at')">Reset<span class="arrow" id="arrow-members-reset_at"></span></th>
<th>Adjust</th>
</tr>
</thead>
<tbody id="rows"><tr><td colspan="8" style="color:var(--muted)">Loading…</td></tr></tbody>
</table>
</div>
<div class="table-scroll" style="margin-top:12px;">
<table id="table-models" style="display:none;">
<thead>
<tr>
<th class="sortable" onclick="sortModels('model')">Model<span class="arrow" id="arrow-models-model"></span></th>
<th class="sortable" onclick="sortModels('spend')">Spend<span class="arrow" id="arrow-models-spend"></span></th>
<th class="sortable" onclick="sortModels('tokens')">Tokens<span class="arrow" id="arrow-models-tokens"></span></th>
<th class="sortable" onclick="sortModels('requests')">Requests<span class="arrow" id="arrow-models-requests"></span></th>
</tr>
</thead>
<tbody id="model-rows"><tr><td colspan="4" style="color:var(--muted)">Loading…</td></tr></tbody>
</table>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
function flash(msg, ok) {
const f = $('flash');
f.textContent = msg;
f.className = 'flash show ' + (ok ? 'ok' : 'err');
setTimeout(() => { f.className = 'flash'; }, 6000);
}
function fmt(n) { return '$' + (Number(n) || 0).toFixed(2); }
async function loadModels() {
try {
const r = await fetch('/api/model-usage?days=' + rangeDays);
if (r.status === 403) return;
const d = await r.json();
if (d.error) { modelsData = []; $('model-rows').innerHTML = '<tr><td colspan="4" style="color:var(--muted)">' + esc(d.error) + '</td></tr>'; return; }
modelsData = d.models || [];
if (currentView === 'models') renderModels();
} catch (e) {
modelsData = [];
$('model-rows').innerHTML = '<tr><td colspan="4" style="color:var(--muted)">Could not load model usage.</td></tr>';
}
}
async function load() {
try {
const r = await fetch('/api/overview');
if (r.status === 403) { document.body.innerHTML = '<h1>Forbidden</h1>'; return; }
const d = await r.json();
$('tot-members').textContent = d.totals.members;
$('tot-spend-cycle').textContent = fmt(d.totals.total_spend);
$('tot-spend-all').textContent = fmt(d.totals.total_all_time_spend ?? d.totals.total_spend);
membersData = d.members || [];
if (currentView === 'members') renderMembers();
} catch (e) {
flash('Could not load: ' + e.message, false);
}
}
// ---- shared state ----
let membersData = [];
let modelsData = [];
let currentView = 'members';
let memberSort = { key: 'spend', dir: -1 }; // leaderboard default: most spend first
let modelSort = { key: 'spend', dir: -1 };
let rangeDays = 0; // 0 = all time
let memberMode = 'balance'; // 'balance' | 'all' — Members view: current-balance figures vs all-time spend
function arrowFor(key, sortState) {
if (sortState.key !== key) return '';
return sortState.dir === 1 ? '▲' : '▼';
}
function clearArrows(table, sortState) {
document.querySelectorAll('#table-' + table + ' .arrow').forEach(el => { el.textContent = ''; });
document.querySelectorAll('#table-' + table + ' th').forEach(th => {
const span = th.querySelector('.arrow');
if (!span) return;
const key = th.getAttribute('onclick')?.match(/'([^']+)'/)?.[1];
if (key) span.textContent = arrowFor(key, sortState);
});
}
function switchView(view) {
currentView = view;
$('table-members').style.display = view === 'members' ? '' : 'none';
$('table-models').style.display = view === 'models' ? '' : 'none';
$('tab-members').className = 'tab-btn' + (view === 'members' ? ' active' : '');
$('tab-models').className = 'tab-btn' + (view === 'models' ? ' active' : '');
$('view-title').textContent = view === 'members' ? 'Members' : 'Model usage';
// Mode groups: Balance/All-time for members; Period for models.
$('range-group').style.display = view === 'models' ? '' : 'none';
$('member-mode-group').style.display = view === 'members' ? '' : 'none';
render();
}
function setRange(days) {
rangeDays = days;
$('range-30').className = 'range-btn' + (days === 30 ? ' active' : '');
$('range-all').className = 'range-btn' + (days === 0 ? ' active' : '');
loadModels();
}
function setMemberMode(mode) {
memberMode = mode;
$('mmode-balance').className = 'range-btn' + (mode === 'balance' ? ' active' : '');
$('mmode-all').className = 'range-btn' + (mode === 'all' ? ' active' : '');
// Header label reflects the mode
$('spend-mode-label').textContent = mode === 'all' ? 'All-time spend' : 'Spend';
// Balance-only columns (Allowance/Credits/Remaining/Reset) hide in all-time mode
$('table-members').parentElement.classList.toggle('members-all-time', mode === 'all');
renderMembers();
}
function sortMembers(key) {
memberSort = { key, dir: memberSort.key === key ? -memberSort.dir : 1 };
clearArrows('members', memberSort);
render();
}
function memberSpend(m) {
return memberMode === 'all' ? (m.all_time_spend ?? m.spend ?? 0) : (m.spend ?? 0);
}
function sortModels(key) {
modelSort = { key, dir: modelSort.key === key ? -modelSort.dir : -1 };
clearArrows('models', modelSort);
render();
}
function render() {
if (currentView === 'members') renderMembers();
else renderModels();
}
function renderMembers() {
const rows = [...membersData].sort((a, b) => {
let va = a[memberSort.key], vb = b[memberSort.key];
if (memberSort.key === 'spend') { va = memberSpend(a); vb = memberSpend(b); }
if (memberSort.key === 'active' || memberSort.key === 'activated') { va = va ? 1 : 0; vb = vb ? 1 : 0; }
if (typeof va === 'string' || typeof vb === 'string') {
return String(va ?? '').localeCompare(String(vb ?? '')) * memberSort.dir;
}
return ((va ?? 0) - (vb ?? 0)) * memberSort.dir;
});
$('rows').innerHTML = rows.map(m => {
const reset = m.reset_at ? m.reset_at.slice(0,10) : '—';
const pill = m.active ? 'active' : 'inactive';
const act = m.activated
? '<span class="pill active">yes</span>'
: '<span class="pill inactive">pending</span>';
const credits = m.credit_balance ?? 0;
const creditsPill = credits > 0
? '<span class="pill active">' + fmt(credits) + '</span>'
: '<span style="color:var(--muted)">—</span>';
const src = m.manual
? '<span class="pill manual" title="Manual member (no OC subscription; exempt from sweep)">manual</span>'
: '<span class="pill active" title="Open Collective Membership subscription">OC</span>';
return '<tr>' +
'<td>' + esc(m.email) + (m.username ? '<div style="color:var(--muted);font-size:11px">' + esc(m.username) + '</div>' : '') + '</td>' +
'<td><span class="pill ' + pill + '">' + pill + '</span></td>' +
'<td>' + act + '</td>' +
'<td>' + src + '</td>' +
'<td class="num balance-col">' + fmt(m.balance) + '</td>' +
'<td class="balance-col">' + creditsPill + '</td>' +
'<td class="num">' + fmt(memberSpend(m)) + '</td>' +
'<td class="num balance-col">' + fmt(m.remaining) + '</td>' +
'<td class="balance-col">' + esc(reset) + '</td>' +
'<td><div class="adjust">' +
'<input id="add-' + i(m.email) + '" placeholder="+$" />' +
'<button data-email="' + esc(m.email) + '" onclick="addBalance(this.dataset.email)" title="Add monthly allowance">Allow</button>' +
'<button data-email="' + esc(m.email) + '" class="credits-btn" onclick="addCredits(this.dataset.email)" title="Add non-expiring credits">Credit</button>' +
'</div></td>' +
'</tr>';
}).join('');
clearArrows('members', memberSort);
}
async function addCredits(email) {
const el = $('add-' + i(email));
const val = parseFloat(el.value);
if (!val || isNaN(val)) { flash('Enter a number', false); return; }
try {
const r = await fetch('/api/set-credits', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ email, add: val, reason: 'admin adjustment (panel)' })
});
const d = await r.json();
if (d.error) { flash(d.error, false); }
else { flash('Added ' + fmt(val) + ' credits to ' + email, true); el.value = ''; load(); }
} catch (e) { flash(e.message, false); }
}
function renderModels() {
const rows = [...modelsData].sort((a, b) => {
const va = a[modelSort.key], vb = b[modelSort.key];
if (typeof va === 'string' || typeof vb === 'string') {
return String(va ?? '').localeCompare(String(vb ?? '')) * modelSort.dir;
}
return ((va ?? 0) - (vb ?? 0)) * modelSort.dir;
});
if (!rows.length) {
$('model-rows').innerHTML = '<tr><td colspan="4" style="color:var(--muted)">No usage recorded.</td></tr>';
return;
}
$('model-rows').innerHTML = rows.map(m =>
'<tr>' +
'<td>' + esc(m.model) + '</td>' +
'<td class="num">' + fmt(m.spend) + '</td>' +
'<td class="num">' + (m.tokens || 0).toLocaleString() + '</td>' +
'<td class="num">' + (m.requests || 0).toLocaleString() + '</td>' +
'</tr>'
).join('');
clearArrows('models', modelSort);
}
function i(email) { return email.replace(/[^a-z0-9]/g, '_'); }
function esc(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
async function addBalance(email) {
const el = $('add-' + i(email));
const val = parseFloat(el.value);
if (!val || isNaN(val)) { flash('Enter a number', false); return; }
try {
const r = await fetch('/api/set-balance', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ email, add: val })
});
const d = await r.json();
if (d.error) { flash(d.error, false); }
else { flash('Added ' + fmt(val) + ' to ' + email, true); el.value = ''; load(); }
} catch (e) { flash(e.message, false); }
}
load();
loadModels();
</script>
</body>
</html>"""
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
return JSONResponse({"error": exc.detail}, status_code=exc.status_code)