# 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'`).