ARCHITECTURE.md
Rama main · réplica de lectura del repositorio original.
# Architecture
Millennium is a modular Django monolith that serves a JSON API, plus a Next.js
single-page frontend that consumes it. This document covers the runtime
topology, the data model, the scheduled jobs, the deploy flow, and the system
invariants that the code refers to by number.
## Overview
- **Backend:** Django 5.2 + Django REST Framework, organized as apps under
`backend/apps/`: `core`, `cards`, `pricing`, `portfolio`, `collection`,
`imports`, `valuation`, `alerts`, `decks`, `status`, `audit`. Each app owns
its models, serializers, viewsets, and any sync or evaluation logic.
- **Frontend:** Next.js (App Router) + React + TypeScript under `frontend/`. It
talks to the backend through a same-origin `/api/*` proxy so the browser sees
one origin and cookies stay first-party.
- **API contract:** drf-spectacular emits an OpenAPI schema; `@hey-api/openapi-ts`
generates a typed TypeScript client from it. Both the schema
(`frontend/openapi.json`) and the generated client are committed, and CI fails
if either drifts from the backend serializers.
## Runtime topology
**Development** (`make dev`, Docker Compose in `infra/docker-compose.yml`):
Postgres 16, Redis, the Django backend (`runserver`), a Celery worker, Celery
beat, and the Next.js dev server. Postgres, Redis, and the backend bind to
`127.0.0.1` only.
**Production** (config-as-code under `infra/hetzner/`): a standalone Caddy edge
is the only public listener. It terminates TLS (automatic certificates) and
reverse-proxies to the Next.js standalone frontend, which in turn proxies
`/api/*` to a gunicorn backend. Postgres runs alongside them. Nothing except the
Caddy edge is published to the public interface; the backend also listens on
`127.0.0.1:8001` for an SSH-tunnelled `/admin/`. Production does not run Redis or
a Celery worker: the cache is Django's database cache on Postgres, and the daily
jobs run as systemd timers instead of beat.
## Data model
A three-level card hierarchy with per-acquisition cost basis:
- **`cards`** are card identities (name, passcode, archetype).
- **`card_printings`** are a specific artwork at a specific rarity in a specific
set. Their natural key is `(card, set_code, set_rarity, variant_label)`.
- **`collection_items`** are one holding: N copies of one printing in one
condition/edition/language/portfolio.
- **`collection_lots`** record each acquisition (quantity, unit cost, date)
under an item, so an item's quantity and cost roll up from its lots rather
than being stored on the item.
- **`external_price_ids`** map a printing to each provider's product ID.
- **`price_snapshots`** are append-only daily prices per printing and edition,
each with a confidence score. Unknown prices are stored as NULL, never zero,
so a consumer can tell "unpriced" from "worth nothing".
- **`portfolio_value_snapshots`** are append-only daily valuations that carry
coverage counts, so a rolled-up total never silently looks complete when some
cards are unpriced.
TCGCSV is the MVP pricing source; YGOPRODeck supplies metadata only.
## Scheduled jobs
Four daily jobs run in sequence: YGOPRODeck metadata sync, TCGCSV price
reconcile-then-ingest, portfolio valuation, and price-alert evaluation. Each
runs through an orchestration entry point (not the bare functions) that applies
a cardinality guard, takes a per-kind Postgres advisory lock so a concurrent run
skips rather than doubling up, and records the outcome in append-only run
history (`SyncRun`, `ValuationRun`, `AlertRun`). Valuation refuses to run unless
a successful same-day pricing run exists, so it never values against stale
prices.
In development these are Celery beat tasks. In production they are systemd
timers. The upstream syncs use `Persistent=true` so they catch up after box
downtime; the dependent jobs (valuation, alerts) use `Persistent=false` so they
do not fire out of order against a stale upstream.
## Deployment and continuous delivery
The deploy config lives under `infra/hetzner/`: the app and edge Compose files,
the systemd units, `deploy.sh` (idempotent, with a `caddy validate` gate before
reloading the edge), and `backup_db.sh`.
Deployment is pull-based. A `millennium-deploy.timer` runs `deploy_poll.sh`
every couple of minutes on the box: it fetches `origin/main`, and if the tip
has advanced past the recorded deployed commit it checks out the new commit
and runs `deploy.sh`. Before it touches the checkout it asks GitHub's public
check-runs API, without a token, whether every required check is green on that
exact commit: queued, running, or absent checks make it wait, with an alert if
the wait passes 30 minutes; a check that finishes with anything but success
alerts at once and holds production on the last good commit until a re-run
passes or a fix lands. The recorded "deployed commit" marker is the source of
truth for what is live, not git `HEAD`: the poller writes the marker only
after `deploy.sh` and a public-route health probe both pass, so a failed
deploy retries on the next tick instead of reporting a false success. Nothing
pushes into the box, so no inbound deploy credential lives on it. A rollback
is a `git revert` on `origin/main`, which the poller then deploys like any
other change.
## Monitoring and backups
- **Host metrics:** a small `/proc` collector runs on the host every couple of
minutes and ingests a metric sample into Postgres through a one-off container,
feeding the read-only `/status` dashboard.
- **Uptime monitoring:** the backup and deploy jobs each ping a dead-man's-switch
monitor on success, so a silent failure raises an alert.
- **Backups:** `backup_db.sh` runs a daily `pg_dump` to off-box object storage.
It fails closed: without a reachable off-box remote it reports failure rather
than a false success, and it does not prune local dumps until the upload is
confirmed. Restores are tested.
## Invariants
These are constraints that are not obvious from reading a single file and that
cause real breakage if forgotten. The code refers to them by number; the
numbering is stable, so a comment that cites "invariant 7" means the entry
below.
1. **Server entrypoints fail closed if `DJANGO_SETTINGS_MODULE` is unset.** The
check lives in `backend/config/__init__.py` and raises, and wsgi/asgi/celery
inherit it through the import chain. This prevents Django's scaffold default
(`os.environ.setdefault(..., "config.settings.dev")`) from silently booting
dev settings in production, which would run with `DEBUG=True`, a wide-open
`ALLOWED_HOSTS`, and a predictable secret. `manage.py` is the one deliberate
exception, as a developer tool.
2. **`backend/config/settings/base.py` defines no environment-sensitive values.**
Not `SECRET_KEY`, `DATABASES`, `CACHES`, `CELERY_BROKER_URL`,
`CELERY_RESULT_BACKEND`, or `ALLOWED_HOSTS`. Each environment module
(`dev`/`prod`/`test`) owns these. If `base.py` supplied a default, `prod.py`
would inherit it and a missing production secret would no longer fail closed.
3. **`base.py` does not load any dotenv file.** `environ.Env.read_env(...)` lives
in `dev.py` only. Otherwise a developer running the prod settings locally with
a dev `.env` present would silently inherit the dev secret and localhost
service URLs.
4. **Lint must pass on every commit.** `make lint` runs ruff and mypy in strict
mode. A documented gate that is allowed to fail is worse than no gate, because
contributors learn to bypass it.
5. **Compose port mappings for `postgres`, `redis`, and the dev `backend`
service bind to `127.0.0.1` only,** never `0.0.0.0` or the bare `port:port`
form. Postgres ships with default credentials, Redis has no password, and the
dev backend runs with `DEBUG=True` (so 500-page tracebacks leak environment
values). Binding to all interfaces would expose them on any shared network.
6. **HSTS in `prod.py` defaults off** (`SECURE_HSTS_SECONDS=0`, and the
include-subdomains and preload flags off), each overridable via
`DJANGO_SECURE_HSTS_*`. Opting into a long max-age with preload before HTTPS
is proven on every subdomain pins browsers (and the global preload list) into
HTTPS for up to a year, and recovery means waiting out the timer. In the live
deployment, HSTS is applied at the Caddy edge instead, because the frontend
proxy hop drops `X-Forwarded-Proto` and Django would otherwise see the request
as insecure.
7. **The OpenAPI schema and docs require authentication and exclude the
read-only demo.** drf-spectacular does not inherit
`REST_FRAMEWORK["DEFAULT_PERMISSION_CLASSES"]`, so its permission is set
explicitly in `SPECTACULAR_SETTINGS["SERVE_PERMISSIONS"]` to a permission that
both requires auth and denies the demo account. The schema describes every
endpoint, field, and filter, which is reconnaissance material for a private
app; the demo is a publicly obtainable session, so plain "authenticated" would
expose the whole surface through it.
8. **The image's Python virtualenv lives at `/opt/venv`, not `/app/.venv`,** and
no Compose volume mounts anything at `/app/.venv`. Docker copies image data
into a named volume only when the volume is first created; a later rebuild
produces a new image but the container still mounts the stale volume on top,
so backend, worker, and beat would silently run old packages.
9. **Changes reach `main` only through the reviewed pull-request flow.** The
`protect-main` ruleset blocks force-pushes and deletions and requires the
status checks to pass; merges are squash-merges of reviewed PRs. Because
continuous delivery auto-deploys `main`, anything that lands there ships.
10. **`CSRF_TRUSTED_ORIGINS` must include every frontend origin that POSTs
through the Next.js `/api/*` proxy** (env-driven in `dev.py`, required with no
default in `prod.py`). The proxy rewrites the Host for Django but the browser
still sends its own `Origin`, and Django's CSRF origin check rejects the
mismatch on every unsafe method, silently returning 403 on writes.
11. **The session cookie stays HttpOnly and the CSRF cookie stays non-HttpOnly;
never override either.** The Django defaults are correct and deliberately
left unset. Making the session cookie readable would let an XSS exfiltrate it
(account takeover); making the CSRF cookie HttpOnly would stop the proxy from
reading the token to echo into `X-CSRFToken`, so every unsafe write would
403.
12. **The edge Caddy Compose mounts the `caddy/` directory, not the single
`Caddyfile`.** A single-file bind mount pins Docker to the file's inode at
container-create time, but the deploy replaces the file (new inode) on every
pull, so a single-file mount would serve the stale config forever and
`caddy reload` would report no change. Mounting the directory avoids this.
13. **The `/ops` observability read endpoints set
`permission_classes = [IsAuthenticated, IsSuperUser]` explicitly,** never the
global default. The global default is passed by the read-only demo for safe
methods, so an `/ops`-adjacent GET that inherited it would let the demo (a
publicly obtainable session) read the audit trail and error logs.