- README: expand the production section with a full systemd recipe (useradd, chown, uv sync --project, install -d for ReadWritePaths, .env perms 640), a Caddy block, and backup note. - AGENTS.md: add the Arch python-uv package-name note and the root-rooted venv caveat, plus a new "Deploy / production" section documenting the volundr unit, data location, .env perms, and forward-proxy expectations.
126 lines
6.3 KiB
Markdown
126 lines
6.3 KiB
Markdown
# AGENTS.md
|
||
|
||
Guidance for working in this repo.
|
||
|
||
## What this is
|
||
|
||
**volundr** (packaged as `bike_admin`) is a small single-user web app for logging bike service/repair
|
||
"events" (type, date, mileage, cost, notes) and seeing per-bike hints: a
|
||
**service-due** status derived from the newest mileage and the most recent
|
||
`service` event, plus **MOT / insurance renewal** statuses derived from the most
|
||
recent `mot` / `insurance` events.
|
||
|
||
Stack: FastAPI (ASGI) + stdlib `sqlite3` (WAL) + Jinja2 + HTMX. Server-rendered
|
||
pages with HTMX partial swaps on the bike page. No build step. Dependencies via
|
||
`uv` (see `pyproject.toml`).
|
||
|
||
## Commands
|
||
|
||
```bash
|
||
uv sync --all-extras # install all deps (fastapi, dev tools)
|
||
uv run uvicorn app.main:app --reload # run dev server (http://127.0.0.1:8000)
|
||
uv run pytest -q # run the test suite
|
||
uv run ruff check app tests # lint
|
||
uv run ruff format --check app tests # format check
|
||
uv run ruff format app tests # apply formatting
|
||
```
|
||
|
||
There is **no `main.py` at the repo root** — the app is the `app` package,
|
||
entry point `app.main:app`.
|
||
|
||
**Arch Linux note:** `uv` ships as `python-uv` (not `uv` or `python3-uv`) so
|
||
`sudo pacman -S --needed python-uv` is the install command. If you create the
|
||
venv with `sudo`, the interpreter will provision under `/root/.local/share/uv`
|
||
and a non-root service user can never execute it; build the venv as the
|
||
service user (or against a system Python — see README "systemd recipe").
|
||
|
||
## Configuration
|
||
|
||
All settings come from env vars (or a `.env` file) via `pydantic-settings`,
|
||
`env_prefix="BIKE_ADMIN_"` (see `app/config.py`).
|
||
|
||
- **Required:** `BIKE_ADMIN_USERNAME`, `BIKE_ADMIN_PASSWORD`. The app **raises at
|
||
startup** if `BIKE_ADMIN_PASSWORD` is not set (no insecure default).
|
||
- `BIKE_ADMIN_SECRET_KEY` — used to derive the per-session CSRF token.
|
||
- `BIKE_ADMIN_IS_PRODUCTION=true` — adds `Secure` to the session cookie (set when
|
||
behind a TLS reverse proxy over HTTPS).
|
||
- `BIKE_ADMIN_BIND_HOST` / `BIND_PORT` — bind address/port.
|
||
- `BIKE_ADMIN_DATA_DIR` — directory for the SQLite file (`bike_admin.db`).
|
||
- Hints: `BIKE_ADMIN_SERVICE_DUE_SOON_MILES` (service "book soon" lead time,
|
||
default 200), `BIKE_ADMIN_RENEWAL_DUE_SOON_DAYS` (MOT/insurance "due soon"
|
||
lead time, default 60), `BIKE_ADMIN_RENEWAL_MONTHS` (MOT/insurance validity,
|
||
default 12).
|
||
|
||
## Deploy / production
|
||
|
||
`deploy/bike_admin.service` is the systemd unit for the production server
|
||
(volundr). Key points:
|
||
|
||
- Runs as the unprivileged `bike` system user (no home dir needed beyond the
|
||
uv-provisioned interpreter in `/home/bike`.local/share/uv/...).
|
||
- App data lives next to the checkout: `/opt/bike_admin/data`. The app creates
|
||
`data/bike_admin.db` on startup, but systemd requires the `ReadWritePaths=`
|
||
target to exist before start — hence the one-time
|
||
`sudo install -d -o bike -g bike /opt/bike_admin/data` step (see README).
|
||
- `.env` at `/opt/bike_admin/.env`, `chown bike:bike` and `chmod 640`.
|
||
(systemd reads it at ExecStart.)
|
||
- Hardening kept: `NoNewPrivileges`, `PrivateTmp`, `ProtectSystem=full`,
|
||
`ProtectHome=true`. Do not remove `ProtectHome` unless the interpreter path
|
||
moves back to `/home`; `StateDirectory=` is the cleaner alternative for
|
||
data, but we chose `ReadWritePaths=` to keep data next to the app.
|
||
|
||
Caddy (hermodr, on a separate host) reverse-proxies
|
||
`volundr.local.jakes-asgard.uk` to the app's IP:8000. The app trusts
|
||
`X-Forwarded-For` (BIKE_ADMIN_TRUST_PROXY defaults true) and uvicorn's
|
||
`--forwarded-allow-ips` whitelist in the unit governs which proxies it'll
|
||
accept.
|
||
|
||
## Architecture / layout
|
||
|
||
- `app/config.py` – `Settings` (pydantic-settings) + `get_settings()` (cached).
|
||
- `app/db.py` – SQLite schema + typed DAO helpers + `get_conn()` context manager.
|
||
- `app/security.py`– Argon2 password hashing, DB session tokens, CSRF, login throttle.
|
||
- `app/services.py`– Business logic: `compute_service_due`, `compute_renewal_due`,
|
||
`summarize_bike`, `all_summaries`.
|
||
- `app/main.py` – App factory, lifespan (schema init + user seed), security-header
|
||
middleware, all routes, HTMX partial rendering.
|
||
- `app/templates/` – Jinja2 templates (login, dashboard, bike detail, event partial).
|
||
- `app/static/` – `main.css`, `favicon.svg`, and vendored `htmx.min.js`.
|
||
- `tests/` – `conftest.py` (Client helper + temp-data-dir env) and per-area
|
||
tests (`test_auth`, `test_due`, `test_renewals`, `test_events`, `test_security`).
|
||
|
||
## Conventions & gotchas
|
||
|
||
- **Starlette's `TemplateResponse(request, name, context)` signature is required.**
|
||
This `starlette` version (1.6.0) uses the new signature — calling
|
||
`TemplateResponse(name, context)` raises `TypeError: unhashable type: 'dict'`.
|
||
Always pass `request` as the first arg (see all call sites in `app/main.py`).
|
||
- **Templates must not contain single `-` inside `{{ }}` as a bare token**, and
|
||
especially avoid `else '- '` — Jinja will mis-lex it (unbalanced braces →
|
||
`TemplateSyntaxError: unexpected char '?' at ...`). Use
|
||
`{% if ... %}{{ x }}{% else %}-{% endif %}` instead.
|
||
- **CSRF:** every mutating form must carry the `csrf` hidden input (token =
|
||
HMAC of `SECRET_KEY` over the session cookie). `_csrf(request)` in `app/main.py`
|
||
returns it for the current session.
|
||
- **Throttling is per-IP and stored in the `login_attempts` table** for the last
|
||
`LOGIN_WINDOW_SECONDS`.
|
||
- **Single user** model; `bikes` and `events` are global (no per-user scoping).
|
||
|
||
### Testing
|
||
|
||
- Tests use `pythonpath = ["."]` (set in `pyproject.toml`) so `import app.*` works.
|
||
- `conftest.py` has an autouse `env` fixture that points `BIKE_ADMIN_DATA_DIR`,
|
||
`BIKE_ADMIN_SECRET_KEY`, `BIKE_ADMIN_USERNAME`, and `BIKE_ADMIN_PASSWORD` to a
|
||
fresh temp dir + known values per test, and clears the `get_settings()` cache.
|
||
- The `Client` helper wraps `TestClient` with `.login()`, `.csrf()`,
|
||
`.create_bike()`, `.create_event()` helpers.
|
||
- `TestClient` is instantiated with `follow_redirects=False` so 303s are observed
|
||
(otherwise successful logins redirect to `/` and you lose the `set-cookie`).
|
||
|
||
### Security headers
|
||
|
||
Applied via an HTTP middleware in `app/main.py`:
|
||
`X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy`,
|
||
`Permissions-Policy`, and a strict CSP (`default-src 'self'`, `script-src 'self'`,
|
||
`frame-ancestors 'none'`).
|