Networking: the superadmin console now mirrors `app` instead of a
loopback-only host port. Base compose drops the host port; the dev
override binds 127.0.0.1:8091; the prod overlay joins the `intranet`
network and listens on :80 with --proxy-headers so NPM can proxy it.
CLI: add `purge-test-users` (dry-run by default, --commit to delete,
--keep allow-list defaulting to the real accounts). Deletes child rows
explicitly (DB-agnostic) plus email-keyed OTPs, so smoke-test signups
that were pointed at prod can be cleaned repeatably instead of via
ad-hoc SQL. Covered by 6 new tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
The app sits behind Nginx Proxy Manager which terminates TLS and
forwards plain HTTP. Without --proxy-headers, Starlette's request.url
keeps the inbound \`http\` scheme, so url_for() renders the static-CSS
\`<link>\` as http://… and the browser blocks it as mixed content under
the public https origin.
Adds --proxy-headers + --forwarded-allow-ips=* to the prod uvicorn
command. The wildcard is fine because the container has no host port —
only the intranet bridge (where NPM lives) can reach it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The shared \`intranet\` Docker network has many other containers
aliased as \`db\` and \`redis\` (mastodon-db, mailman redis, etc.).
Docker's embedded DNS resolved those before our own services, sending
the app's MySQL connection to the wrong IP and crashing it in a restart
loop.
Switched both DATABASE_URL and REDIS_URL in the prod overlay to use
the project-prefixed container names (readmarkets-db-1,
readmarkets-redis-1), which are globally unique on the Docker daemon.
Applied to both app and scheduler.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Compose merges list-typed fields like \`ports\` by concatenation, so the
previous prod overlay couldn't clear the base file's host port binding;
the VPS app ended up listening on both port 80 (intranet) AND host port
8800 simultaneously.
Restructured to the conventional dev/prod split:
- docker-compose.yml: no host port — prod-ready by default
- docker-compose.override.yml: dev-only host port binding (auto-loaded
by \`docker compose up\` locally, skipped when prod uses explicit -f)
- docker-compose.prod.yml: command-port 80 + intranet network only
Production invocation:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The VPS deployment sits behind Nginx Proxy Manager on a pre-existing
`intranet` Docker bridge network. The overlay drops the host port
binding from the base compose, switches uvicorn to listen on port 80
inside the container (uniform NPM upstreams), and joins the app to
both `default` (for db/redis) and `intranet` (for NPM ingress).
Apply with:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
Local dev (compose without the overlay) is unchanged — still binds
the host port from CASSANDRA_PORT in .env.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>