admin: internal-only superadmin console (users, payments, DB stats)

New independent `admin` service (admin.main:app) on the same image, reusing
app.db/app.models read-only. Never runs migrations or the scheduler; issues
SELECTs only.

- Password-gated (ADMIN_CONSOLE_PASSWORD) with a 12h signed cookie; closed by
  default when the password is empty.
- Bound to 127.0.0.1:8091 (SSH-tunnel access); off the intranet/NPM network.
- Pages: overview stats, user list + search, per-user history/payment detail,
  DB usage (information_schema size + row estimates).
- Compose: base `admin` service (+prod DB-host override, test mount); Dockerfile
  bakes admin/ into runtime + test stages.
- Tests: tests/test_admin_console.py (auth, queries, page wiring) — 12 passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-07-01 16:08:20 +02:00
parent 8946dee2e0
commit 411094d7b8
20 changed files with 1143 additions and 1 deletions

View file

@ -16,6 +16,13 @@ OPENROUTER_API_KEY= # OpenRouter (AI log generation)
# --- App ---
CASSANDRA_TOKEN= # Bearer token required if set; LAN-only no-auth if empty
CASSANDRA_PORT=8000
# --- Superadmin console (internal-only, separate `admin` container) ---
# Password for the read-only operator dashboard at 127.0.0.1:8091 (reach it
# via `ssh -L 8091:localhost:8091 <vps>`). Empty = console refuses every
# login (closed by default). Set a strong value in prod.
ADMIN_CONSOLE_PASSWORD=
ADMIN_CONSOLE_SESSION_SECRET= # cookie signing key; falls back to CASSANDRA_SESSION_SECRET/TOKEN if empty
CASSANDRA_BASE_CURRENCY=GBP
CASSANDRA_ANCHOR_DATE=2026-03-04 # YYYY-MM-DD; used by market_pulse anchor column
CASSANDRA_MOCK=0 # 1 = serve canned fixtures, skip live APIs

View file

@ -32,10 +32,12 @@ RUN apt-get update \
COPY --from=builder /opt/venv /opt/venv
WORKDIR /app
COPY app ./app
COPY admin ./admin
COPY alembic ./alembic
COPY alembic.ini ./
# Default command is the web app; scheduler container overrides via `command:`.
# Default command is the web app; the scheduler and admin-console containers
# override via `command:` (see docker-compose.yml).
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
@ -57,6 +59,7 @@ COPY --from=builder /opt/venv /opt/venv
WORKDIR /app
COPY pyproject.toml requirements.lock ./
COPY app ./app
COPY admin ./admin
COPY alembic ./alembic
COPY alembic.ini ./
# tests/ is excluded by .dockerignore (prod-correct: never bake tests into

50
admin/README.md Normal file
View file

@ -0,0 +1,50 @@
# Superadmin console
An **independent, internal-only** web service for the operator: user list with
per-user history + payment status, and database usage stats. Runs in its own
container (`admin` service) off the same image as the main app, reusing
`app.db` + `app.models`, but it is a separate FastAPI app (`admin.main:app`)
that **never** runs migrations or the scheduler and only ever issues `SELECT`s.
## Access model
- Bound to **`127.0.0.1:8091`** on the host — never exposed publicly, not on
the `intranet`/NPM network. Reach it over an SSH tunnel:
```sh
ssh -L 8091:localhost:8091 <vps>
# then open http://localhost:8091
```
- Gated by a single shared password, `ADMIN_CONSOLE_PASSWORD` (in `.env`).
Empty password ⇒ every login is refused (closed by default). The login sets
a 12-hour signed cookie (`admin_console_session`).
## Pages
- `/` — overview: totals, tier split, paid-active, signups 7/30d, sync count,
referral conversions, newest users.
- `/users` — full user table with email search + paging.
- `/users/{id}` — one user: account, computed paid status, Stripe/Polar
linkage, cloud-sync state, referrals sent, feedback votes, digest-email log,
legal acknowledgements.
- `/db` — per-table row estimates and on-disk size (from
`information_schema.tables`), with each table's share of total.
## Deploy (this host runs prod)
Adding a **new** service means `up -d` (a plain `restart` won't create it).
Do NOT run a bare `docker compose up` on this host — always pass the prod
overlay:
```sh
# 1. set ADMIN_CONSOLE_PASSWORD in .env
# 2. build + create just the admin container (leaves app/scheduler/db running)
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build admin
```
## Tests
```sh
docker compose -f docker-compose.test.yml run --rm test pytest tests/test_admin_console.py -v
```

9
admin/__init__.py Normal file
View file

@ -0,0 +1,9 @@
"""Superadmin console — an independent, internal-only web service.
Runs in its own container (see the `admin` service in docker-compose.yml),
bound to 127.0.0.1 on the VPS and reached over an SSH tunnel. It reuses the
main app's ORM models and DB engine (`app.db`, `app.models`) but is a wholly
separate FastAPI app: it never imports `app.main`, so it does NOT run Alembic
migrations or the app's lifespan. Every DB access is a plain SELECT — the
console never writes.
"""

71
admin/auth.py Normal file
View file

@ -0,0 +1,71 @@
"""Password gate for the superadmin console.
A single shared password (``ADMIN_CONSOLE_PASSWORD``) is exchanged at
``/login`` for a signed, time-limited session cookie. There is no user
identity here the console has exactly one principal, "the operator".
The cookie is signed with itsdangerous using ``ADMIN_CONSOLE_SESSION_SECRET``
(falling back to the main app's session secret / token), mirroring the scheme
in ``app.auth`` but under a distinct salt so an app session cookie can never
be replayed against the console and vice-versa.
"""
from __future__ import annotations
import secrets
from fastapi import HTTPException, Request, status
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from app.config import get_settings
SESSION_COOKIE_NAME = "admin_console_session"
SESSION_TTL_SECONDS = 12 * 60 * 60 # 12 hours — re-auth daily-ish.
_SALT = "admin-console-session-v1"
def _serializer() -> URLSafeTimedSerializer:
s = get_settings()
secret = (
s.ADMIN_CONSOLE_SESSION_SECRET
or s.CASSANDRA_SESSION_SECRET
or s.CASSANDRA_TOKEN
or "dev-insecure-secret"
)
return URLSafeTimedSerializer(secret, salt=_SALT)
def check_password(provided: str) -> bool:
"""Constant-time compare against ``ADMIN_CONSOLE_PASSWORD``. Returns
False when the password is unset the console is closed by default so a
fresh deploy can't be logged into without an explicit password."""
expected = get_settings().ADMIN_CONSOLE_PASSWORD
if not expected:
return False
return secrets.compare_digest(provided.encode(), expected.encode())
def sign_session() -> str:
"""Signed value proving a successful password login. Carries no identity
beyond a version marker the console has a single principal."""
return _serializer().dumps({"v": 1})
def verify_session(cookie: str) -> bool:
try:
data = _serializer().loads(cookie, max_age=SESSION_TTL_SECONDS)
return data.get("v") == 1
except (BadSignature, SignatureExpired, KeyError, TypeError, ValueError):
return False
def require_admin(request: Request) -> None:
"""FastAPI dependency guarding every console page. Valid cookie → passes;
otherwise 303 /login (browser) so the operator lands on the form."""
cookie = request.cookies.get(SESSION_COOKIE_NAME)
if cookie and verify_session(cookie):
return
raise HTTPException(
status_code=status.HTTP_303_SEE_OTHER,
detail="Login required",
headers={"Location": "/login"},
)

140
admin/main.py Normal file
View file

@ -0,0 +1,140 @@
"""Superadmin console FastAPI app.
Independent of ``app.main``: it wires only its own routes and reuses
``app.db``'s session factory for read-only queries. No lifespan, no
migrations, no scheduler.
Run: uvicorn admin.main:app --host 0.0.0.0 --port 8000
"""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from fastapi import Depends, FastAPI, Form, Query, Request
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.ext.asyncio import AsyncSession
from admin import auth, queries
from app.db import get_session
TEMPLATE_DIR = Path(__file__).resolve().parent / "templates"
templates = Jinja2Templates(directory=str(TEMPLATE_DIR))
def _fmt_bytes(n: int | None) -> str:
if not n:
return "0 B"
units = ["B", "KB", "MB", "GB", "TB"]
size = float(n)
for u in units:
if size < 1024 or u == units[-1]:
return f"{size:,.0f} {u}" if u == "B" else f"{size:,.1f} {u}"
size /= 1024
return f"{n} B"
def _fmt_dt(dt: datetime | None) -> str:
if dt is None:
return ""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.strftime("%Y-%m-%d %H:%M UTC")
templates.env.filters["bytes"] = _fmt_bytes
templates.env.filters["dt"] = _fmt_dt
app = FastAPI(title="read.markets superadmin console", docs_url=None, redoc_url=None)
@app.get("/healthz", response_class=PlainTextResponse, include_in_schema=False)
async def healthz() -> str:
return "ok"
@app.get("/login", response_class=HTMLResponse, include_in_schema=False)
async def login_form(request: Request):
# Already signed in? Skip the form.
cookie = request.cookies.get(auth.SESSION_COOKIE_NAME)
if cookie and auth.verify_session(cookie):
return RedirectResponse("/", status_code=303)
return templates.TemplateResponse(request, "login.html", {"error": None})
@app.post("/login", response_class=HTMLResponse, include_in_schema=False)
async def login_submit(request: Request, password: str = Form(...)):
if not auth.check_password(password):
return templates.TemplateResponse(
request, "login.html", {"error": "Incorrect password."},
status_code=401,
)
resp = RedirectResponse("/", status_code=303)
resp.set_cookie(
auth.SESSION_COOKIE_NAME,
auth.sign_session(),
max_age=auth.SESSION_TTL_SECONDS,
httponly=True,
samesite="lax",
# secure=False: the console is served over http on a localhost SSH
# tunnel, never public TLS. A secure cookie would never be sent.
secure=False,
)
return resp
@app.get("/logout", include_in_schema=False)
async def logout():
resp = RedirectResponse("/login", status_code=303)
resp.delete_cookie(auth.SESSION_COOKIE_NAME)
return resp
@app.get("/", response_class=HTMLResponse, include_in_schema=False,
dependencies=[Depends(auth.require_admin)])
async def overview(request: Request, session: AsyncSession = Depends(get_session)):
stats = await queries.overview_stats(session)
recent, _ = await queries.list_users(session, limit=10)
return templates.TemplateResponse(
request, "overview.html", {"stats": stats, "recent": recent},
)
@app.get("/users", response_class=HTMLResponse, include_in_schema=False,
dependencies=[Depends(auth.require_admin)])
async def users_list(
request: Request,
session: AsyncSession = Depends(get_session),
q: str | None = Query(default=None),
page: int = Query(default=1, ge=1),
):
per_page = 200
offset = (page - 1) * per_page
rows, total = await queries.list_users(session, q=q, limit=per_page, offset=offset)
return templates.TemplateResponse(
request, "users.html",
{"rows": rows, "total": total, "q": q or "",
"page": page, "per_page": per_page},
)
@app.get("/users/{user_id}", response_class=HTMLResponse, include_in_schema=False,
dependencies=[Depends(auth.require_admin)])
async def user_detail(
request: Request, user_id: int,
session: AsyncSession = Depends(get_session),
):
detail = await queries.user_detail(session, user_id)
if detail is None:
return templates.TemplateResponse(
request, "not_found.html", {"user_id": user_id}, status_code=404,
)
return templates.TemplateResponse(request, "user_detail.html", {"d": detail})
@app.get("/db", response_class=HTMLResponse, include_in_schema=False,
dependencies=[Depends(auth.require_admin)])
async def db_page(request: Request, session: AsyncSession = Depends(get_session)):
stats = await queries.db_stats(session)
return templates.TemplateResponse(request, "db.html", {"stats": stats})

266
admin/queries.py Normal file
View file

@ -0,0 +1,266 @@
"""Read-only queries backing the console. Every function issues SELECTs
only the console never mutates the database.
Payment status is computed with the app's own ``paid_status`` so the console
never diverges from what the app considers "paid".
"""
from __future__ import annotations
from datetime import timedelta
from sqlalchemy import func, or_, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import utcnow
from app.models import (
EmailSend,
PortfolioSync,
Referral,
StrategicLogFeedback,
User,
UserAcknowledgement,
)
from app.services.access import paid_status
async def overview_stats(session: AsyncSession) -> dict:
"""Headline counts for the landing page: totals, tier split, growth,
paid-active, engagement."""
now = utcnow()
d7 = now - timedelta(days=7)
d30 = now - timedelta(days=30)
total = await session.scalar(select(func.count(User.id))) or 0
tier_rows = (await session.execute(
select(User.tier, func.count(User.id)).group_by(User.tier)
)).all()
tiers = {tier: n for tier, n in tier_rows}
signups_7d = await session.scalar(
select(func.count(User.id)).where(User.created_at >= d7)
) or 0
signups_30d = await session.scalar(
select(func.count(User.id)).where(User.created_at >= d30)
) or 0
active_30d = await session.scalar(
select(func.count(User.id)).where(User.last_login_at >= d30)
) or 0
# Paid-active = subscribed tier OR a live credit window. The two are
# counted disjointly (a credit user whose tier is already paid is only
# counted once, under tier) so the sum can't double-count.
paid_by_tier = await session.scalar(
select(func.count(User.id)).where(User.tier.in_(("paid", "enterprise")))
) or 0
credit_active = await session.scalar(
select(func.count(User.id)).where(
User.credit_until.is_not(None),
User.credit_until > now,
User.tier.not_in(("paid", "enterprise")),
)
) or 0
sync_enabled = await session.scalar(
select(func.count(PortfolioSync.user_id))
) or 0
referrals_total = await session.scalar(select(func.count(Referral.id))) or 0
referrals_converted = await session.scalar(
select(func.count(Referral.id)).where(Referral.converted_at.is_not(None))
) or 0
return {
"total_users": total,
"tiers": tiers,
"free": tiers.get("free", 0),
"paid": tiers.get("paid", 0),
"enterprise": tiers.get("enterprise", 0),
"paid_active": paid_by_tier + credit_active,
"credit_active": credit_active,
"signups_7d": signups_7d,
"signups_30d": signups_30d,
"active_30d": active_30d,
"sync_enabled": sync_enabled,
"referrals_total": referrals_total,
"referrals_converted": referrals_converted,
}
async def list_users(
session: AsyncSession,
q: str | None = None,
limit: int = 200,
offset: int = 0,
) -> tuple[list[dict], int]:
"""User rows for the table view, newest first. Optional ``q`` filters by
email substring. Returns (rows, total_matching)."""
stmt = select(User)
count_stmt = select(func.count(User.id))
if q:
like = f"%{q.strip()}%"
stmt = stmt.where(User.email.like(like))
count_stmt = count_stmt.where(User.email.like(like))
total = await session.scalar(count_stmt) or 0
users = (await session.execute(
stmt.order_by(User.created_at.desc()).limit(limit).offset(offset)
)).scalars().all()
if not users:
return [], total
ids = [u.id for u in users]
# One grouped query each for the per-user badges, merged in Python —
# avoids an N+1 without a wide join fan-out.
sync_ids = set((await session.execute(
select(PortfolioSync.user_id).where(PortfolioSync.user_id.in_(ids))
)).scalars().all())
ref_counts = dict((await session.execute(
select(Referral.referrer_user_id, func.count(Referral.id))
.where(Referral.referrer_user_id.in_(ids))
.group_by(Referral.referrer_user_id)
)).all())
rows: list[dict] = []
for u in users:
ps = paid_status(u)
rows.append({
"id": u.id,
"email": u.email,
"tier": u.tier,
"paid_active": ps.active,
"paid_source": ps.source,
"credit_days": ps.days_remaining,
"created_at": u.created_at,
"last_login_at": u.last_login_at,
"lang": u.lang,
"has_sync": u.id in sync_ids,
"referrals": ref_counts.get(u.id, 0),
"on_stripe": bool(u.stripe_customer_id),
"on_polar": bool(u.polar_customer_id),
"trialing": u.stripe_trial_end_at is not None,
})
return rows, total
async def user_detail(session: AsyncSession, user_id: int) -> dict | None:
"""Everything the console shows for one user: the row itself, computed
payment status, billing linkage, and joined activity history."""
user = await session.get(User, user_id)
if user is None:
return None
ps = paid_status(user)
referred_by = None
if user.referred_by_user_id:
ref = await session.get(User, user.referred_by_user_id)
referred_by = {"id": ref.id, "email": ref.email} if ref else None
# Referrals this user sent (with conversion state).
sent = (await session.execute(
select(Referral, User.email)
.join(User, User.id == Referral.referred_user_id)
.where(Referral.referrer_user_id == user_id)
.order_by(Referral.created_at.desc())
)).all()
referrals_sent = [{
"referred_email": email,
"created_at": r.created_at,
"converted_at": r.converted_at,
} for r, email in sent]
# Digest email delivery log (most recent first, capped).
email_rows = (await session.execute(
select(EmailSend).where(EmailSend.user_id == user_id)
.order_by(EmailSend.sent_at.desc()).limit(25)
)).scalars().all()
emails = [{
"kind": e.kind, "sent_at": e.sent_at,
"status": e.status, "error": e.error,
} for e in email_rows]
# Legal acknowledgements (evidentiary audit trail).
ack_rows = (await session.execute(
select(UserAcknowledgement).where(UserAcknowledgement.user_id == user_id)
.order_by(UserAcknowledgement.accepted_at.desc())
)).scalars().all()
acks = [{
"version": a.version, "lang": a.lang, "accepted_at": a.accepted_at,
} for a in ack_rows]
# Feedback votes cast on strategic logs.
fb_rows = (await session.execute(
select(StrategicLogFeedback.vote, func.count(StrategicLogFeedback.id))
.where(StrategicLogFeedback.user_id == user_id)
.group_by(StrategicLogFeedback.vote)
)).all()
feedback = {vote: n for vote, n in fb_rows}
sync = await session.get(PortfolioSync, user_id)
return {
"user": user,
"paid": ps,
"referred_by": referred_by,
"referrals_sent": referrals_sent,
"emails": emails,
"acks": acks,
"feedback": feedback,
"sync": {
"enabled": sync is not None,
"updated_at": sync.updated_at if sync else None,
"version": sync.version if sync else None,
},
}
async def db_stats(session: AsyncSession) -> dict:
"""Per-table size and row estimates for the current schema.
On MariaDB this reads ``information_schema.tables`` (row counts are the
engine's estimate for InnoDB, sizes are exact). On sqlite (the test
backend) there is no such view, so it falls back to real ``COUNT(*)``
per table with sizes reported as 0.
"""
dialect = session.bind.dialect.name
if dialect == "sqlite":
from app.db import Base
tables = []
for tbl in Base.metadata.sorted_tables:
n = await session.scalar(select(func.count()).select_from(tbl)) or 0
tables.append({
"name": tbl.name, "rows": n,
"data_bytes": 0, "index_bytes": 0, "total_bytes": 0, "pct": 0.0,
})
tables.sort(key=lambda t: t["rows"], reverse=True)
return {"tables": tables, "total_bytes": 0, "total_rows": sum(t["rows"] for t in tables)}
rows = (await session.execute(text(
"SELECT table_name, table_rows, data_length, index_length "
"FROM information_schema.tables "
"WHERE table_schema = DATABASE() "
"ORDER BY (data_length + index_length) DESC"
))).all()
tables = []
total_bytes = 0
total_rows = 0
for name, tr, data_len, idx_len in rows:
data_len = int(data_len or 0)
idx_len = int(idx_len or 0)
tot = data_len + idx_len
total_bytes += tot
total_rows += int(tr or 0)
tables.append({
"name": name, "rows": int(tr or 0),
"data_bytes": data_len, "index_bytes": idx_len, "total_bytes": tot,
})
for t in tables:
t["pct"] = round(100.0 * t["total_bytes"] / total_bytes, 1) if total_bytes else 0.0
return {"tables": tables, "total_bytes": total_bytes, "total_rows": total_rows}

80
admin/templates/base.html Normal file
View file

@ -0,0 +1,80 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>{% block title %}superadmin{% endblock %} · read.markets</title>
<style>
:root {
--bg:#0d1117; --panel:#161b22; --border:#30363d; --fg:#c9d1d9;
--muted:#8b949e; --accent:#58a6ff; --good:#3fb950; --warn:#d29922;
--bad:#f85149; --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
}
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--fg);
font:14px/1.5 var(--mono); }
a { color:var(--accent); text-decoration:none; }
a:hover { text-decoration:underline; }
header { display:flex; align-items:center; gap:1.5rem; padding:.75rem 1.25rem;
background:var(--panel); border-bottom:1px solid var(--border); }
header .brand { font-weight:700; color:#fff; }
header nav { display:flex; gap:1rem; }
header .spacer { flex:1; }
main { padding:1.25rem; max-width:1200px; margin:0 auto; }
h1 { font-size:1.25rem; margin:0 0 1rem; }
h2 { font-size:1rem; color:var(--muted); text-transform:uppercase;
letter-spacing:.05em; margin:1.5rem 0 .5rem; }
.cards { display:grid; grid-template-columns:repeat(auto-fill,minmax(160px,1fr));
gap:.75rem; }
.card { background:var(--panel); border:1px solid var(--border);
border-radius:6px; padding:.75rem 1rem; }
.card .n { font-size:1.6rem; font-weight:700; color:#fff; }
.card .l { color:var(--muted); font-size:.8rem; }
table { width:100%; border-collapse:collapse; margin-top:.5rem; }
th,td { text-align:left; padding:.4rem .6rem; border-bottom:1px solid var(--border);
white-space:nowrap; }
th { color:var(--muted); font-weight:600; font-size:.8rem;
text-transform:uppercase; letter-spacing:.04em; }
tr:hover td { background:#1c2230; }
.pill { display:inline-block; padding:.05rem .5rem; border-radius:999px;
font-size:.75rem; border:1px solid var(--border); }
.pill.good { color:var(--good); border-color:#238636; }
.pill.warn { color:var(--warn); border-color:#9e6a03; }
.pill.bad { color:var(--bad); border-color:#a1281f; }
.pill.mute { color:var(--muted); }
.muted { color:var(--muted); }
.bar { height:8px; background:#21262d; border-radius:4px; overflow:hidden;
min-width:80px; }
.bar > span { display:block; height:100%; background:var(--accent); }
form.search { margin:.5rem 0; }
input[type=text],input[type=password] {
background:#0d1117; border:1px solid var(--border); color:var(--fg);
padding:.4rem .6rem; border-radius:6px; font:inherit; }
button { background:#238636; color:#fff; border:0; padding:.45rem .9rem;
border-radius:6px; font:inherit; cursor:pointer; }
button:hover { background:#2ea043; }
.kv { display:grid; grid-template-columns:180px 1fr; gap:.25rem 1rem; }
.kv dt { color:var(--muted); }
.kv dd { margin:0; }
.login { max-width:340px; margin:12vh auto; background:var(--panel);
border:1px solid var(--border); border-radius:8px; padding:1.5rem; }
.err { color:var(--bad); margin:.5rem 0; }
</style>
</head>
<body>
{% if show_nav|default(true) %}
<header>
<span class="brand">read.markets · superadmin</span>
<nav>
<a href="/">Overview</a>
<a href="/users">Users</a>
<a href="/db">Database</a>
</nav>
<span class="spacer"></span>
<a href="/logout">Log out</a>
</header>
{% endif %}
<main>{% block body %}{% endblock %}</main>
</body>
</html>

31
admin/templates/db.html Normal file
View file

@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block title %}database{% endblock %}
{% block body %}
<h1>Database usage</h1>
<div class="cards">
<div class="card"><div class="n">{{ stats.total_bytes|bytes }}</div><div class="l">Total size</div></div>
<div class="card"><div class="n">{{ "{:,}".format(stats.total_rows) }}</div><div class="l">Total rows (est.)</div></div>
<div class="card"><div class="n">{{ stats.tables|length }}</div><div class="l">Tables</div></div>
</div>
<h2>Per table</h2>
<table>
<thead><tr><th>Table</th><th>Rows (est.)</th><th>Data</th><th>Index</th><th>Total</th><th style="width:160px">Share</th></tr></thead>
<tbody>
{% for t in stats.tables %}
<tr>
<td>{{ t.name }}</td>
<td>{{ "{:,}".format(t.rows) }}</td>
<td class="muted">{{ t.data_bytes|bytes }}</td>
<td class="muted">{{ t.index_bytes|bytes }}</td>
<td>{{ t.total_bytes|bytes }}</td>
<td>
<div class="bar"><span style="width:{{ t.pct }}%"></span></div>
<span class="muted">{{ t.pct }}%</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="muted">Row counts are the storage engine's estimate for InnoDB; sizes are exact on-disk bytes.</p>
{% endblock %}

View file

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% set show_nav = false %}
{% block title %}login{% endblock %}
{% block body %}
<div class="login">
<h1>Superadmin</h1>
{% if error %}<div class="err">{{ error }}</div>{% endif %}
<form method="post" action="/login">
<p><input type="password" name="password" placeholder="Password"
autofocus autocomplete="current-password" style="width:100%"></p>
<button type="submit">Sign in</button>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,6 @@
{% extends "base.html" %}
{% block title %}not found{% endblock %}
{% block body %}
<h1>User #{{ user_id }} not found</h1>
<p><a href="/users">← back to users</a></p>
{% endblock %}

View file

@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}overview{% endblock %}
{% block body %}
<h1>Overview</h1>
<div class="cards">
<div class="card"><div class="n">{{ stats.total_users }}</div><div class="l">Total users</div></div>
<div class="card"><div class="n">{{ stats.paid_active }}</div><div class="l">Paid-active</div></div>
<div class="card"><div class="n">{{ stats.free }}</div><div class="l">Free tier</div></div>
<div class="card"><div class="n">{{ stats.paid }}</div><div class="l">Paid tier</div></div>
<div class="card"><div class="n">{{ stats.enterprise }}</div><div class="l">Enterprise</div></div>
<div class="card"><div class="n">{{ stats.credit_active }}</div><div class="l">On credit</div></div>
<div class="card"><div class="n">{{ stats.signups_7d }}</div><div class="l">Signups · 7d</div></div>
<div class="card"><div class="n">{{ stats.signups_30d }}</div><div class="l">Signups · 30d</div></div>
<div class="card"><div class="n">{{ stats.active_30d }}</div><div class="l">Active · 30d</div></div>
<div class="card"><div class="n">{{ stats.sync_enabled }}</div><div class="l">Cloud-sync on</div></div>
<div class="card"><div class="n">{{ stats.referrals_converted }}/{{ stats.referrals_total }}</div><div class="l">Referrals conv.</div></div>
</div>
<h2>Newest users</h2>
{% include "partials_users_table.html" %}
<p><a href="/users">All users →</a></p>
{% endblock %}

View file

@ -0,0 +1,39 @@
{# expects `recent` (overview) or `rows` (users list) — normalise to `rows` #}
{% set rows = rows if rows is defined else recent %}
<table>
<thead>
<tr>
<th>ID</th><th>Email</th><th>Tier</th><th>Paid</th><th>Created</th>
<th>Last login</th><th>Lang</th><th>Sync</th><th>Refs</th><th>Billing</th>
</tr>
</thead>
<tbody>
{% for u in rows %}
<tr>
<td>{{ u.id }}</td>
<td><a href="/users/{{ u.id }}">{{ u.email }}</a></td>
<td>{{ u.tier }}</td>
<td>
{% if u.paid_active %}
<span class="pill good">yes{% if u.paid_source == 'credit' %} · {{ u.credit_days }}d{% endif %}</span>
{% else %}
<span class="pill mute">no</span>
{% endif %}
{% if u.trialing %}<span class="pill warn">trial</span>{% endif %}
</td>
<td class="muted">{{ u.created_at|dt }}</td>
<td class="muted">{{ u.last_login_at|dt }}</td>
<td>{{ u.lang }}</td>
<td>{% if u.has_sync %}<span class="pill good">on</span>{% else %}<span class="muted"></span>{% endif %}</td>
<td>{{ u.referrals }}</td>
<td>
{% if u.on_stripe %}<span class="pill">stripe</span>{% endif %}
{% if u.on_polar %}<span class="pill">polar</span>{% endif %}
{% if not u.on_stripe and not u.on_polar %}<span class="muted"></span>{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="10" class="muted">No users.</td></tr>
{% endfor %}
</tbody>
</table>

View file

@ -0,0 +1,73 @@
{% extends "base.html" %}
{% set u = d.user %}
{% block title %}{{ u.email }}{% endblock %}
{% block body %}
<p class="muted"><a href="/users">← users</a></p>
<h1>{{ u.email }} <span class="muted">#{{ u.id }}</span></h1>
<h2>Account</h2>
<dl class="kv">
<dt>Tier</dt><dd>{{ u.tier }}</dd>
<dt>Paid status</dt><dd>
{% if d.paid.active %}
<span class="pill good">active</span> via {{ d.paid.source }}
{% if d.paid.source == 'credit' %}· expires {{ d.paid.expires_at|dt }} ({{ d.paid.days_remaining }}d){% endif %}
{% else %}<span class="pill mute">inactive</span>{% endif %}
</dd>
<dt>Credit until</dt><dd>{{ u.credit_until|dt }}</dd>
<dt>Created</dt><dd>{{ u.created_at|dt }}</dd>
<dt>Last login</dt><dd>{{ u.last_login_at|dt }}</dd>
<dt>Language</dt><dd>{{ u.lang }}</dd>
<dt>Digest opt-in</dt><dd>{{ 'yes' if u.email_digest_opt_in else 'no' }}{% if u.digest_tone %} · {{ u.digest_tone }}{% endif %}</dd>
<dt>Referral code</dt><dd>{{ u.referral_code or '—' }}</dd>
<dt>Referred by</dt><dd>{% if d.referred_by %}<a href="/users/{{ d.referred_by.id }}">{{ d.referred_by.email }}</a>{% else %}—{% endif %}</dd>
</dl>
<h2>Billing linkage</h2>
<dl class="kv">
<dt>Stripe customer</dt><dd>{{ u.stripe_customer_id or '—' }}</dd>
<dt>Stripe subscription</dt><dd>{{ u.stripe_subscription_id or '—' }}</dd>
<dt>Stripe trial ends</dt><dd>{{ u.stripe_trial_end_at|dt }}</dd>
<dt>Polar customer</dt><dd>{{ u.polar_customer_id or '—' }}</dd>
<dt>Polar subscription</dt><dd>{{ u.polar_subscription_id or '—' }}</dd>
</dl>
<h2>Cloud sync</h2>
{% if d.sync.enabled %}
<p><span class="pill good">enabled</span> · v{{ d.sync.version }} · updated {{ d.sync.updated_at|dt }}
<span class="muted">(contents are end-to-end encrypted — not readable here)</span></p>
{% else %}<p class="muted">Not enabled.</p>{% endif %}
<h2>Referrals sent ({{ d.referrals_sent|length }})</h2>
{% if d.referrals_sent %}
<table><thead><tr><th>Referred</th><th>Sent</th><th>Converted</th></tr></thead><tbody>
{% for r in d.referrals_sent %}
<tr><td>{{ r.referred_email }}</td><td class="muted">{{ r.created_at|dt }}</td>
<td>{% if r.converted_at %}<span class="pill good">{{ r.converted_at|dt }}</span>{% else %}<span class="muted">pending</span>{% endif %}</td></tr>
{% endfor %}
</tbody></table>
{% else %}<p class="muted">None.</p>{% endif %}
<h2>Feedback votes</h2>
<p>👍 {{ d.feedback.get('up', 0) }} · 👎 {{ d.feedback.get('down', 0) }}</p>
<h2>Digest emails (last 25)</h2>
{% if d.emails %}
<table><thead><tr><th>Kind</th><th>Sent</th><th>Status</th><th>Error</th></tr></thead><tbody>
{% for e in d.emails %}
<tr><td>{{ e.kind }}</td><td class="muted">{{ e.sent_at|dt }}</td>
<td>{% if e.status == 'sent' %}<span class="pill good">sent</span>{% elif e.status == 'error' %}<span class="pill bad">error</span>{% else %}<span class="pill warn">{{ e.status }}</span>{% endif %}</td>
<td class="muted">{{ e.error or '' }}</td></tr>
{% endfor %}
</tbody></table>
{% else %}<p class="muted">None.</p>{% endif %}
<h2>Legal acknowledgements</h2>
{% if d.acks %}
<table><thead><tr><th>Version</th><th>Lang</th><th>Accepted</th></tr></thead><tbody>
{% for a in d.acks %}
<tr><td>v{{ a.version }}</td><td>{{ a.lang }}</td><td class="muted">{{ a.accepted_at|dt }}</td></tr>
{% endfor %}
</tbody></table>
{% else %}<p class="muted">None recorded.</p>{% endif %}
{% endblock %}

View file

@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}users{% endblock %}
{% block body %}
<h1>Users <span class="muted">({{ total }})</span></h1>
<form class="search" method="get" action="/users">
<input type="text" name="q" value="{{ q }}" placeholder="filter by email…">
<button type="submit">Search</button>
{% if q %}<a href="/users" style="margin-left:.5rem">clear</a>{% endif %}
</form>
{% include "partials_users_table.html" %}
{% set pages = (total // per_page) + (1 if total % per_page else 0) %}
{% if pages > 1 %}
<p class="muted">
Page {{ page }} / {{ pages }}
{% if page > 1 %}· <a href="/users?page={{ page-1 }}{% if q %}&q={{ q }}{% endif %}">prev</a>{% endif %}
{% if page < pages %}· <a href="/users?page={{ page+1 }}{% if q %}&q={{ q }}{% endif %}">next</a>{% endif %}
</p>
{% endif %}
{% endblock %}

View file

@ -50,6 +50,17 @@ class Settings(BaseSettings):
# created. Phase A leaves this open so the operator can self-onboard.
CASSANDRA_SIGNUP_ENABLED: bool = True
# Superadmin console (independent container, internal-only — bound to
# 127.0.0.1 on the VPS, reached via SSH tunnel). Read-only operator
# dashboard: user list, per-user history/payment status, DB usage stats.
# A single shared password gates it; empty ADMIN_CONSOLE_PASSWORD makes
# every login attempt fail (the console is closed rather than open by
# default, so a fresh deploy can't accidentally expose it).
ADMIN_CONSOLE_PASSWORD: str = ""
# Signing secret for the console's session cookie. Falls back to
# CASSANDRA_SESSION_SECRET / CASSANDRA_TOKEN like the main app's cookie.
ADMIN_CONSOLE_SESSION_SECRET: str = ""
# SMTP for email OTP verification. If SMTP_SERVER is empty, OTP codes
# are written to stdout instead of sent — convenient for local dev.
SMTP_SERVER: str = ""

View file

@ -44,6 +44,14 @@ services:
DATABASE_URL: mysql+aiomysql://${MARIADB_USER:-cassandra}:${MARIADB_PASSWORD:-changeme}@readmarkets-db-1:3306/${MARIADB_DATABASE:-cassandra}
REDIS_URL: redis://readmarkets-redis-1:6379/0
admin:
# Same DNS-collision reasoning as app/scheduler: use the project-prefixed
# container name for the DB. The console stays OFF the intranet network —
# it is internal-only (127.0.0.1:8091 host port from the base file), so it
# never needs to be reachable by NPM.
environment:
DATABASE_URL: mysql+aiomysql://${MARIADB_USER:-cassandra}:${MARIADB_PASSWORD:-changeme}@readmarkets-db-1:3306/${MARIADB_DATABASE:-cassandra}
networks:
intranet:
external: true

View file

@ -33,6 +33,7 @@ services:
# on the next `run` without rebuilding the image.
volumes:
- ./app:/app/app
- ./admin:/app/admin
- ./tests:/app/tests
- ./alembic:/app/alembic
- ./alembic.ini:/app/alembic.ini:ro

View file

@ -77,6 +77,33 @@ services:
redis:
condition: service_healthy
# Superadmin console — independent read-only operator dashboard. Same
# image (reuses app.db/app.models) but runs admin.main:app instead of the
# public app, and NEVER runs migrations. Bound to 127.0.0.1 only: it is
# reached over an SSH tunnel, never exposed publicly (no intranet/NPM).
admin:
build: .
restart: unless-stopped
command: ["uvicorn", "admin.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
env_file: .env
environment:
DATABASE_URL: mysql+aiomysql://${MARIADB_USER:-cassandra}:${MARIADB_PASSWORD:-changeme}@db:3306/${MARIADB_DATABASE:-cassandra}
volumes:
- ./config:/app/config:ro
- ./app:/app/app
- ./admin:/app/admin
ports:
# Host-loopback only — access via `ssh -L 8091:localhost:8091 <vps>`.
- "127.0.0.1:8091:8000"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8000/healthz"]
interval: 30s
timeout: 5s
retries: 3
depends_on:
db:
condition: service_healthy
backup:
image: mariadb:11
restart: unless-stopped

264
tests/test_admin_console.py Normal file
View file

@ -0,0 +1,264 @@
"""Superadmin console — auth gate, read-only queries, and page wiring.
Runs against an in-memory-ish sqlite file (same pattern as the other API
tests): seed a handful of users + related rows, rebind app.db to the test
engine, then drive both the query layer directly and the FastAPI app via
TestClient.
"""
from __future__ import annotations
import asyncio
from datetime import timedelta
def _seed(tmp_path):
"""Create schema + fixture rows; rebind app.db. Returns the factory."""
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app import db as db_mod
from app.db import Base, utcnow
from app.models import (
EmailSend, PortfolioSync, Referral, StrategicLogFeedback,
User, UserAcknowledgement,
)
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/admin.db")
factory = async_sessionmaker(engine, expire_on_commit=False)
db_mod._engine = engine
db_mod._session_factory = factory
now = utcnow()
async def _go():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with factory() as s:
# 1: free, referrer
s.add(User(id=1, email="alice@x", tier="free", created_at=now,
last_login_at=now, referral_code="ALICE"))
# 2: paid subscriber via Stripe
s.add(User(id=2, email="bob@x", tier="paid", created_at=now,
last_login_at=now, stripe_customer_id="cus_1",
stripe_subscription_id="sub_1"))
# 3: free but credit-active, referred by alice, has sync/history
s.add(User(id=3, email="carol@x", tier="free", created_at=now,
credit_until=now + timedelta(days=30),
referred_by_user_id=1))
await s.flush()
s.add(PortfolioSync(user_id=3, outer_ciphertext=b"x",
outer_nonce=b"y", version=1,
created_at=now, updated_at=now))
s.add(EmailSend(user_id=3, kind="daily", sent_at=now, status="sent"))
s.add(UserAcknowledgement(user_id=3, version=2, lang="en",
accepted_at=now))
s.add(StrategicLogFeedback(log_id=1, user_id=3, vote="up",
created_at=now, updated_at=now))
s.add(Referral(referrer_user_id=1, referred_user_id=3,
created_at=now, converted_at=now))
await s.commit()
asyncio.run(_go())
return factory
# --- auth ------------------------------------------------------------------
def test_check_password(monkeypatch):
from app.config import get_settings
from admin import auth
monkeypatch.setenv("ADMIN_CONSOLE_PASSWORD", "s3cret")
get_settings.cache_clear()
assert auth.check_password("s3cret") is True
assert auth.check_password("wrong") is False
get_settings.cache_clear()
def test_check_password_empty_denies(monkeypatch):
from app.config import get_settings
from admin import auth
monkeypatch.setenv("ADMIN_CONSOLE_PASSWORD", "")
get_settings.cache_clear()
# Empty password must reject everything — console closed by default.
assert auth.check_password("") is False
assert auth.check_password("anything") is False
get_settings.cache_clear()
def test_session_round_trip(monkeypatch):
from app.config import get_settings
from admin import auth
monkeypatch.setenv("ADMIN_CONSOLE_SESSION_SECRET", "unit-test-secret")
get_settings.cache_clear()
token = auth.sign_session()
assert auth.verify_session(token) is True
assert auth.verify_session("garbage") is False
get_settings.cache_clear()
# --- queries ---------------------------------------------------------------
def test_overview_stats(tmp_path):
factory = _seed(tmp_path)
from admin import queries
async def _go():
async with factory() as s:
return await queries.overview_stats(s)
stats = asyncio.run(_go())
assert stats["total_users"] == 3
assert stats["free"] == 2
assert stats["paid"] == 1
# bob (tier paid) + carol (credit) both count as paid-active.
assert stats["paid_active"] == 2
assert stats["credit_active"] == 1
assert stats["sync_enabled"] == 1
assert stats["referrals_total"] == 1
assert stats["referrals_converted"] == 1
def test_list_users(tmp_path):
factory = _seed(tmp_path)
from admin import queries
async def _go():
async with factory() as s:
return await queries.list_users(s)
rows, total = asyncio.run(_go())
assert total == 3
by_id = {r["id"]: r for r in rows}
assert by_id[2]["paid_active"] and by_id[2]["paid_source"] == "tier"
assert by_id[2]["on_stripe"] is True
assert by_id[3]["paid_active"] and by_id[3]["paid_source"] == "credit"
assert by_id[3]["has_sync"] is True
assert by_id[1]["referrals"] == 1
assert by_id[1]["paid_active"] is False
def test_list_users_search(tmp_path):
factory = _seed(tmp_path)
from admin import queries
async def _go():
async with factory() as s:
return await queries.list_users(s, q="bob")
rows, total = asyncio.run(_go())
assert total == 1 and rows[0]["email"] == "bob@x"
def test_user_detail(tmp_path):
factory = _seed(tmp_path)
from admin import queries
async def _go():
async with factory() as s:
return await queries.user_detail(s, 3)
d = asyncio.run(_go())
assert d is not None
assert d["user"].email == "carol@x"
assert d["paid"].active and d["paid"].source == "credit"
assert d["referred_by"]["email"] == "alice@x"
assert d["sync"]["enabled"] is True
assert d["feedback"].get("up") == 1
assert len(d["emails"]) == 1
assert len(d["acks"]) == 1
def test_user_detail_missing(tmp_path):
factory = _seed(tmp_path)
from admin import queries
async def _go():
async with factory() as s:
return await queries.user_detail(s, 999)
assert asyncio.run(_go()) is None
def test_db_stats_sqlite_fallback(tmp_path):
factory = _seed(tmp_path)
from admin import queries
async def _go():
async with factory() as s:
return await queries.db_stats(s)
stats = asyncio.run(_go())
names = {t["name"] for t in stats["tables"]}
assert "users" in names
users_row = next(t for t in stats["tables"] if t["name"] == "users")
assert users_row["rows"] == 3
# --- app wiring ------------------------------------------------------------
def _client(tmp_path, monkeypatch):
from fastapi.testclient import TestClient
from app.config import get_settings
_seed(tmp_path)
monkeypatch.setenv("ADMIN_CONSOLE_PASSWORD", "letmein")
get_settings.cache_clear()
from admin.main import app
return TestClient(app, follow_redirects=False)
def test_pages_require_login(tmp_path, monkeypatch):
client = _client(tmp_path, monkeypatch)
for path in ("/", "/users", "/users/1", "/db"):
r = client.get(path)
assert r.status_code == 303, path
assert r.headers["location"] == "/login"
get_settings_clear()
def test_login_flow(tmp_path, monkeypatch):
client = _client(tmp_path, monkeypatch)
# Wrong password → 401, no cookie.
r = client.post("/login", data={"password": "nope"})
assert r.status_code == 401
assert "admin_console_session" not in r.cookies
# Right password → 303 to / with a session cookie.
r = client.post("/login", data={"password": "letmein"})
assert r.status_code == 303
assert r.headers["location"] == "/"
cookie = r.cookies.get("admin_console_session")
assert cookie
# Authenticated pages now load.
r = client.get("/", cookies={"admin_console_session": cookie})
assert r.status_code == 200
assert "Overview" in r.text
r = client.get("/users", cookies={"admin_console_session": cookie})
assert r.status_code == 200
assert "carol@x" in r.text
r = client.get("/users/3", cookies={"admin_console_session": cookie})
assert r.status_code == 200
assert "carol@x" in r.text and "alice@x" in r.text
r = client.get("/db", cookies={"admin_console_session": cookie})
assert r.status_code == 200
assert "users" in r.text
get_settings_clear()
def test_healthz(tmp_path, monkeypatch):
client = _client(tmp_path, monkeypatch)
r = client.get("/healthz")
assert r.status_code == 200 and r.text == "ok"
get_settings_clear()
def get_settings_clear():
from app.config import get_settings
get_settings.cache_clear()