Files
bike_admin/AGENTS.md
T
Jake 4576e6fae3 Docs: production deployment recipe + Arch gotchas
- 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.
2026-09-10 13:25:19 +01:00

6.3 KiB
Raw Blame History

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

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